<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

use PpPhp\Core\Build\SocialAuth\SocialAuthBuilder;
use PpPhp\Core\Designer\Build\Contracts\BuildManifest;
use PpPhp\Core\Designer\Build\Contracts\BuildArtifact;
use PpPhp\Core\Events\EventCatalogue;
use PpPhp\Core\Theme\ThemeCompiler;
use PpPhp\Core\Theme\ThemeRegistry;

final class BuildWriter
{
    private array $logs = [];
    private array $filesWritten = [];

    /**
     * Project context gathered by BuildEngine from every designer module:
     * slug, title, menu (Menu Editor tree), shell (active theme shell config),
     * designer (designer.json state), pages (pages.json byTable), misc
     * (misc.json byTable + project), security (security.json).
     */
    private array $ctx = [];

    /** Table whose Add/Edit form is currently being generated (for per-field runtime extras). */
    private string $currentFormTable = '';
    private ?string $defaultThemeId = null;
    /** Memoized admin-seed decision (see adminSeed()). */
    private ?array $adminSeedCache = null;
    /** When true, the build ships a runtime install.php instead of baking
     *  credentials + admin into the bundle (Output → "Include standalone installer"). */
    public bool $standaloneInstaller = false;

    /** Public site URL (Output → Base URL), stashed for the standalone sitemap/robots. */
    private string $outBaseUrl = '';
    /** Resolved fields per table (name→fields), set at generateApplicationPages so
     *  designFor() can synthesise a default template for never-designed pages. */
    private array $tableFields = [];
    /** Memoized synthesised default templates, keyed "table|pageType". */
    private array $defaultTemplateCache = [];
    /** Memoized project-wide icon-library usage {fa:bool, material:bool}. */
    private ?array $iconLibsCache = null;

    /**
     * Whether the project uses FontAwesome / Material Symbols icons ANYWHERE
     * (any module config). FA icons always carry a `fa-` token and Material a
     * `material-symbols` token (ComponentRenderer::icon), and the shell template
     * + app.js use only Bootstrap Icons — so an absent token means the font is
     * genuinely unused and its (heavy) stylesheet can be dropped from pages.
     *
     * @return array{fa:bool, material:bool}
     */
    private function iconLibsUsed(): array
    {
        if ($this->iconLibsCache === null) {
            $blob = json_encode($this->ctx) ?: '';
            $this->iconLibsCache = [
                'fa'       => strpos($blob, 'fa-') !== false,
                'material' => strpos($blob, 'material-symbols') !== false,
            ];
        }
        return $this->iconLibsCache;
    }

    public function withContext(array $ctx): void
    {
        $this->ctx = $ctx;
    }

    /** Resolved page design for a table/pageType: saved design, else the default template. */
    private function designFor(string $table, string $pageType): array
    {
        $designer = $this->ctx['designer'] ?? [];
        $slot = $designer['pages'][$table][$pageType] ?? null;
        if (is_array($slot)) {
            $pages = $slot['pages'] ?? [];
            $name = (string) ($slot['defaultPage'] ?? '');
            $page = $pages[$name] ?? (is_array($pages) && $pages !== [] ? reset($pages) : null);
            if (is_array($page) && !empty($page['components'])) {
                return $page;
            }
        }
        $tpl = $designer['templates'][$pageType] ?? null;
        if (is_array($tpl) && !empty($tpl['components'])) {
            return $tpl;
        }
        // No saved design and no cached template → synthesise the SAME default the
        // Designer's "Reset to Default" produces (TemplateFactory with the table's
        // real fields), so a project built without ever opening the Designer gets
        // the polished, aligned pages instead of a flatter hand-rolled fallback.
        return $this->defaultTemplateFor($table, $pageType);
    }

    /**
     * The default page design for a table/pageType — identical to what the
     * Designer's "Reset to Default" writes — synthesised from TemplateFactory
     * using the table's real fields. Returns [] for page types TemplateFactory
     * does not template (callers then use their own plain fallback).
     */
    private function defaultTemplateFor(string $table, string $pageType): array
    {
        $key = $table . '|' . $pageType;
        if (array_key_exists($key, $this->defaultTemplateCache)) {
            return $this->defaultTemplateCache[$key];
        }

        $arr = [];
        try {
            $tpl = \PpPhp\Core\Designer\TemplateFactory::make($table, $pageType, $this->templateFieldsFor($table));
            $arr = $tpl?->toArray() ?? [];
            // Templates embed stdClass placeholders ((object)[] → "{}" in JSON, the
            // shape the Designer frontend expects). A SAVED page round-trips through
            // JSON and reaches the build as pure arrays — give the synthesised
            // default the same treatment, or array access on those objects fatals.
            $arr = json_decode((string) json_encode($arr), true) ?: [];
        } catch (\Throwable $e) {
            $this->log("Default template for {$table}/{$pageType} unavailable: " . $e->getMessage());
            $arr = [];
        }

        return $this->defaultTemplateCache[$key] = is_array($arr) ? $arr : [];
    }

    /**
     * The table's resolved build fields reshaped for TemplateFactory: carries the
     * per-page visibility flags (list/add/edit/view) as `showOn` so the synthesised
     * default respects the Fields module choices, same as a Designer reset.
     */
    private function templateFieldsFor(string $table): array
    {
        $out = [];
        foreach ((array) ($this->tableFields[$table] ?? []) as $f) {
            if (!is_array($f) || ($f['name'] ?? '') === '') continue;
            $out[] = [
                'name'     => (string) $f['name'],
                'label'    => (string) ($f['label'] ?? ucwords(str_replace(['_', '-'], ' ', (string) $f['name']))),
                'type'     => (string) ($f['type'] ?? 'text'),
                'required' => (bool) ($f['required'] ?? false),
                'enabled'  => ($f['enabled'] ?? true) !== false,
                'showOn'   => [
                    'list' => (bool) ($f['list'] ?? true),
                    'add'  => (bool) ($f['add'] ?? true),
                    'edit' => (bool) ($f['edit'] ?? true),
                    'view' => (bool) ($f['view'] ?? true),
                ],
            ];
        }
        return $out;
    }

    /** Resolved project-wide design such as Login, Profile, or Register. */
    private function commonDesignFor(string $pageType): array
    {
        $page = $this->ctx['designer']['common'][$pageType] ?? null;
        return is_array($page) && !empty($page['components']) ? $page : [];
    }

    /** First FULL component node of each type (props + style + visibility), keyed by type. */
    private function componentNodes(array $design): array
    {
        $map = [];
        $walk = function (array $components) use (&$map, &$walk): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                $type = (string) ($c['type'] ?? '');
                if ($type !== '' && !isset($map[$type])) $map[$type] = $c;
                if (is_array($c['children'] ?? null)) $walk($c['children']);
            }
        };
        $walk((array) ($design['components'] ?? []));
        return $map;
    }

    /**
     * Resolve a component's chrome from its Style panel settings:
     * attribute string (class + inline style), collected custom CSS, visibility.
     *
     * @return array{class: string, style: string, css: string, visible: bool}
     */
    private function componentChrome(?array $node): array
    {
        if (!is_array($node)) {
            return ['class' => '', 'style' => '', 'css' => '', 'visible' => true];
        }
        $style = (array) ($node['style'] ?? []);
        $ok = static fn(string $v): bool => $v !== '' && !preg_match('/[<>{}]/', $v);

        $map = [
            'color' => 'color', 'background' => 'background', 'font' => 'font-family',
            'fontSize' => 'font-size', 'fontWeight' => 'font-weight', 'align' => 'text-align',
            'padding' => 'padding', 'margin' => 'margin', 'width' => 'width',
            'border' => 'border', 'borderRadius' => 'border-radius', 'shadow' => 'box-shadow',
        ];
        $css = [];
        foreach ($map as $key => $prop) {
            $v = trim((string) ($style[$key] ?? ''));
            if ($ok($v)) $css[] = $prop . ':' . $v;
        }

        return [
            'class'   => trim(preg_replace('/[^a-zA-Z0-9 _-]/', '', (string) ($style['class'] ?? ''))),
            'style'   => implode(';', $css),
            'css'     => trim((string) ($style['customCss'] ?? '')),
            'visible' => ($node['visibility']['visible'] ?? true) !== false,
        ];
    }

    /**
     * Head extras + <html> class from a page's Properties Panel — so the SEO &
     * sharing, Advanced → Head code and Layout → Dark mode controls are LIVE on
     * ordinary table pages (list/view), not just custom/standalone pages.
     * Returns ['head' => <meta/link/raw>, 'htmlCls' => ' class="is-dark"'|''].
     */
    private function pageSettingsHead(array $design, string $titleEsc): array
    {
        $seo  = (array) ($design['settings'] ?? []);
        $head = '';
        // Description → description + Open Graph + Twitter.
        $metaDesc = trim((string) ($seo['description'] ?? ''));
        if ($metaDesc !== '') {
            $d = htmlspecialchars($metaDesc, ENT_QUOTES);
            $head .= '<meta name="description" content="' . $d . '"><meta property="og:description" content="' . $d . '"><meta name="twitter:description" content="' . $d . '">';
        }
        $head .= '<meta property="og:type" content="website"><meta property="og:title" content="' . $titleEsc . '"><meta name="twitter:title" content="' . $titleEsc . '">';
        // Social share image.
        $ogImage = trim((string) ($seo['ogImage'] ?? ''));
        if ($ogImage !== '' && preg_match('~^https?://~i', $ogImage)) {
            $img = htmlspecialchars($ogImage, ENT_QUOTES);
            $head .= '<meta property="og:image" content="' . $img . '"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="' . $img . '">';
        } else {
            $head .= '<meta name="twitter:card" content="summary">';
        }
        // Canonical URL.
        $canonical = trim((string) ($seo['canonical'] ?? ''));
        if ($canonical !== '' && preg_match('~^https?://~i', $canonical)) {
            $head .= '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES) . '">';
        }
        // Search-engine exclusion.
        if (filter_var($seo['noindex'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
            $head .= '<meta name="robots" content="noindex, nofollow">';
        }
        // Favicon — explicit, else the Style logo/favicon; relative paths resolve
        // against the app root at runtime (URLs/data URIs pass through).
        $favicon = trim((string) ($seo['favicon'] ?? ''));
        if ($favicon === '') {
            $__sh = (array) ($this->ctx['shell'] ?? []);
            $favicon = trim((string) ($__sh['favicon'] ?? '')) ?: trim((string) (($__sh['logo']['image']) ?? ''));
        }
        if ($favicon !== '' && !preg_match('~^\s*(javascript|vbscript)\s*:~i', $favicon)) {
            $fav = htmlspecialchars($favicon, ENT_QUOTES);
            if (!preg_match('~^(https?:)?//|^data:~i', $favicon)) {
                $fav = '<?= APP_BASE ?>/' . ltrim($fav, '/');
            }
            $head .= '<link rel="icon" href="' . $fav . '"><link rel="apple-touch-icon" href="' . $fav . '">';
        }
        // Raw head code (Advanced → Head code): analytics / verification / fonts.
        $head .= (string) ($seo['headCode'] ?? '');
        // Per-page dark mode → is-dark on <html> (theme's dark-variant tokens).
        $htmlCls = filter_var(((array) ($design['layout'] ?? []))['dark'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' class="is-dark"' : '';
        return ['head' => $head, 'htmlCls' => $htmlCls];
    }

    /** First component of each type in a design, keyed by type (flattened one level). */
    private function componentProps(array $design): array
    {
        $map = [];
        $walk = function (array $components) use (&$map, &$walk): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                $type = (string) ($c['type'] ?? '');
                if ($type !== '' && !isset($map[$type])) {
                    $map[$type] = (array) ($c['props'] ?? []);
                }
                if (is_array($c['children'] ?? null)) $walk($c['children']);
            }
        };
        $walk((array) ($design['components'] ?? []));
        return $map;
    }

    /**
     * Field components placed in a designed form page (section > form > field),
     * in designer order, with their per-field props.
     *
     * @return array<int, array{field:string,label:string,inputType:string,required:bool,placeholder:string,help:string,span:int}>
     */
    private function designFormFields(array $design): array
    {
        $out = [];
        $walk = function (array $components) use (&$out, &$walk): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'field' && ($c['visibility']['visible'] ?? true)) {
                    $out[] = $this->designFieldConfig($c);
                }
                if (is_array($c['children'] ?? null)) $walk($c['children']);
            }
        };
        $walk((array) ($design['components'] ?? []));
        return array_values(array_filter($out, static fn(array $f): bool => $f['field'] !== ''));
    }

    /** Full per-field config from a designed field component (form pages). */
    private function designFieldConfig(array $c): array
    {
        $p = (array) ($c['props'] ?? []);
        $clamp = static fn ($v, int $d): int => (is_numeric($v) && (int) $v >= 1 && (int) $v <= 12) ? (int) $v : $d;
        $spans = is_array($p['spans'] ?? null) ? $p['spans'] : [];
        $span = $clamp($spans['desktop'] ?? ($p['spans'] ?? 12), 12);
        $options = $p['options'] ?? [];
        if (is_string($options)) {
            $options = array_values(array_filter(array_map('trim', explode(',', $options))));
        }

        return [
            'field'        => (string) ($p['field'] ?? ''),
            'label'        => (string) ($p['label'] ?? $c['label'] ?? $p['field'] ?? ''),
            'inputType'    => (string) ($p['inputType'] ?? 'text'),
            'required'     => !empty($p['required']),
            'placeholder'  => (string) ($p['placeholder'] ?? ''),
            'help'         => (string) ($p['help'] ?? ''),
            'icon'         => (string) ($p['icon'] ?? ''),
            'labelStyle'   => (string) ($p['labelStyle'] ?? 'default'),
            'iconPre'      => (string) ($p['iconPre'] ?? $p['icon'] ?? ''),
            'iconPost'     => (string) ($p['iconPost'] ?? ''),
            'labelWidth'   => (string) ($p['labelWidth'] ?? ''),
            'labelGap'     => (string) ($p['labelGap'] ?? ''),
            'options'      => array_map('strval', (array) $options),
            'defaultValue' => (string) ($p['defaultValue'] ?? ''),
            'autoUpdate'   => trim((string) ($p['autoUpdate'] ?? '')),
            'autoUpdateOnAdd' => !empty($p['autoUpdateOnAdd']),
            'editControl'  => (string) ($p['editControl'] ?? ''),
            'editSettings' => (array) ($p['editSettings'] ?? []),
            'validations'  => self::sanitizeValidations($p['validations'] ?? []),
            'span'         => $span,
            'spanTablet'   => $clamp($spans['tablet'] ?? null, $span),
            'spanMobile'   => $clamp($spans['mobile'] ?? null, 12),
            // Style panel → Min height: grows the control (textarea) / wrapper
            // in the generated form, so column heights can be balanced by hand.
            'minHeight'    => trim((string) ($c['style']['minHeight'] ?? '')),
        ];
    }

    /**
     * Normalise a field's validations[] into clean rule dicts
     * ([type, params, message]). Drops malformed / typeless entries.
     *
     * @return array<int,array{type:string,params:array<string,mixed>,message:string}>
     */
    private static function sanitizeValidations(mixed $raw): array
    {
        if (!is_array($raw)) return [];
        $out = [];
        foreach ($raw as $rule) {
            if (!is_array($rule)) continue;
            $type = trim((string) ($rule['type'] ?? ''));
            if ($type === '') continue;
            $entry = [
                'type'    => $type,
                'params'  => is_array($rule['params'] ?? null) ? $rule['params'] : [],
                'message' => (string) ($rule['message'] ?? ''),
            ];
            // Optional conditional gate — only applies when another field matches.
            $w = $rule['when'] ?? null;
            if (is_array($w) && trim((string) ($w['field'] ?? '')) !== '') {
                $entry['when'] = [
                    'field' => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($w['field'] ?? '')),
                    'op'    => in_array(($w['op'] ?? '=='), ['==', '!=', 'empty', 'notEmpty', 'in', 'contains'], true) ? (string) $w['op'] : '==',
                    'value' => (string) ($w['value'] ?? ''),
                ];
            }
            $out[] = $entry;
        }
        return $out;
    }

    /**
     * Project server-side rules onto the small client vocabulary the in-page
     * validator understands, and pre-interpolate each message (label + params
     * are known at build time). Server-only rules (empty projection) are
     * dropped from the client payload — the server pass still enforces them.
     *
     * @param array<string,array<int,array<string,mixed>>> $vrules
     * @param array<string,string>                         $vlabels
     * @return array<string,array{label:string,rules:array<int,array<string,mixed>>}>
     */
    private function buildClientVRules(array $vrules, array $vlabels): array
    {
        $items = \PpPhp\Core\PluginRegistry::validation();
        $out = [];
        foreach ($vrules as $field => $rules) {
            $label = $vlabels[$field] ?? $field;
            $entries = [];
            foreach ($rules as $r) {
                $type = (string) ($r['type'] ?? '');
                $cls = $items[$type]['class'] ?? null;
                if ($cls === null || !class_exists($cls)) continue;
                $params = is_array($r['params'] ?? null) ? $r['params'] : [];
                try { $proj = (array) $cls::clientRule($params); }
                catch (\Throwable $e) { $proj = []; }
                if ($proj === []) continue; // server-only rule → not on the client
                $msg = trim((string) ($r['message'] ?? ''));
                if ($msg === '') { $msg = (string) $cls::defaultMessage(); }
                $proj['msg'] = self::interpolateMessage($msg, $label, $params);
                // Carry the conditional gate so the in-page validator skips the
                // rule too when the condition isn't met (client/server parity).
                if (is_array($r['when'] ?? null) && ($r['when']['field'] ?? '') !== '') {
                    $proj['when'] = ['field' => (string) $r['when']['field'], 'op' => (string) ($r['when']['op'] ?? '=='), 'value' => (string) ($r['when']['value'] ?? '')];
                }
                $entries[] = $proj;
            }
            if ($entries !== []) {
                $out[$field] = ['label' => $label, 'rules' => $entries];
            }
        }
        return $out;
    }

    /** Compact, safe PHP literal for a data-only array (var_export, one line). */
    private static function phpArrayLiteral(array $data): string
    {
        $s = var_export($data, true);
        // var_export emits "array (\n …\n)"; collapse to a compact inline literal.
        return preg_replace('/\s+/', ' ', $s) ?? '[]';
    }

    /** Replace {label} / {paramKey} tokens (mirror of Validator::interpolate). */
    private static function interpolateMessage(string $msg, string $label, array $params): string
    {
        $repl = ['{label}' => $label !== '' ? $label : 'This field'];
        foreach ($params as $k => $v) {
            if (is_scalar($v)) $repl['{' . $k . '}'] = (string) $v;
        }
        return strtr($msg, $repl);
    }

    /**
     * Per-field validation rules for a table's Add/Edit forms, keyed by DB field.
     * Drives both the generated client-side validator and the server-side pass.
     * A field designed on both add and edit keeps the edit-page rules (loaded last).
     *
     * @return array<string, array<int,array<string,mixed>>>
     */
    private function validationsFor(string $table, string $mode): array
    {
        $out = [];
        foreach ($this->designFormFields($this->designFor($table, $mode)) as $f) {
            $rules = self::sanitizeValidations($f['validations'] ?? []);
            if ($rules !== [] && ($f['field'] ?? '') !== '') {
                $out[$f['field']] = $rules;
            }
        }
        return $out;
    }

    /**
     * Each field's edit control harvested from the table's designed Add/Edit
     * forms (where the control is actually configured). Used as a fallback for
     * the LIST page's inline edit / spreadsheet / inline add, whose grid columns
     * usually carry no edit control of their own — so a plugin control (Nepali
     * Date Picker, Dropdown, …) chosen on the form works in the list too.
     *
     * @return array<string, array{editControl:string, editSettings:array}>
     */
    /**
     * A field's PER-PAGE control override for a given page (list/add/edit/view/
     * search), or null when the field uses its category default. Set once via the
     * Designer's "Different settings per page" field-controls modal
     * (fields.byTable[t].pageAs[field][page] = {type,settings}).
     *
     * @return array{control:string, settings:array}|null
     */
    private function perPageControl(array $f, string $page): ?array
    {
        $pa = $f['pageAs'][$page] ?? null;
        if (is_array($pa) && (string) ($pa['type'] ?? '') !== '') {
            return ['control' => (string) $pa['type'], 'settings' => (array) ($pa['settings'] ?? [])];
        }
        return null;
    }

    private function editControlsFor(string $table): array
    {
        $out = [];
        foreach (['add', 'edit'] as $pt) {
            foreach ($this->designFormFields($this->designFor($table, $pt)) as $f) {
                $ec = (string) ($f['editControl'] ?? '');
                $fn = (string) ($f['field'] ?? '');
                if ($fn !== '' && $ec !== '' && $ec !== 'TextBox' && !isset($out[$fn])) {
                    $out[$fn] = ['editControl' => $ec, 'editSettings' => (array) ($f['editSettings'] ?? [])];
                }
            }
        }
        return $out;
    }

    /**
     * Lookup (foreign-key) fields for a table, harvested from its designed
     * Add/Edit forms: any field whose edit control is the Lookup Wizard with a
     * complete configuration. Used to translate stored keys into display
     * values on the list and view pages ("configure once, shown everywhere").
     *
     * @return array<string, array{table:string, key:string, display:string}>
     */
    /**
     * The table's PRIMARY KEY column, detected from the live schema. Generated
     * apps historically assumed `id`; an imported table can have any PK (e.g.
     * `std_id`), which broke every `ORDER BY id` / `WHERE id = ?`. Baked into the
     * generated SQL at build time — a no-op for the common `id` case.
     */
    private array $pkCache = [];
    private array $pkAutoCache = [];
    private function primaryKeyFor(string $table): string
    {
        if (array_key_exists($table, $this->pkCache)) return $this->pkCache[$table];
        // Prefer the PK BuildEngine detected while the project DB was active —
        // later build phases may leave a different connection active, so a lazy
        // SHOW COLUMNS here can silently miss and fall back to 'id'.
        $ctxPk = $this->ctx['primaryKeys'][$table] ?? '';
        if (is_string($ctxPk) && $ctxPk !== '') return $this->pkCache[$table] = $ctxPk;
        $pk = 'id';
        try {
            $cols = \PpPhp\Core\DB::Columns($table);   // driver-aware (MySQL/SQLite)
            $found = '';
            foreach ($cols as $col) {
                if (($col['Key'] ?? '') === 'PRI') { $found = (string) $col['Field']; break; }
            }
            // A composite PK (or none) can't drive single-row addressing, so fall
            // back to `id` when present, else the first column.
            if ($found !== '') {
                $pk = $found;
            } elseif ($cols !== []) {
                $names = array_map(static fn($c) => (string) $c['Field'], $cols);
                $pk = in_array('id', $names, true) ? 'id' : (string) $names[0];
            }
        } catch (\Throwable) {
            // schema unavailable (headless/no-DB build) → keep 'id'
        }
        return $this->pkCache[$table] = $pk;
    }

    /**
     * Whether the table's primary key is AUTO_INCREMENT (the DB assigns it).
     * Drives form read-only rendering and INSERT/UPDATE column exclusion: an
     * auto PK is locked on Add/Edit and never written; a manual PK (no AI) stays
     * user-editable on Add. Prefers BuildEngine's connection-time detection.
     */
    private function primaryKeyIsAutoFor(string $table): bool
    {
        if (array_key_exists($table, $this->pkAutoCache)) return $this->pkAutoCache[$table];
        $ctx = $this->ctx['primaryKeyAuto'][$table] ?? null;
        if (is_bool($ctx)) return $this->pkAutoCache[$table] = $ctx;
        // Lazy fallback (headless build): introspect Extra for auto_increment/identity.
        $auto = true; // most PKs are auto; only downgrade on positive evidence
        try {
            $pk = $this->primaryKeyFor($table);
            foreach (\PpPhp\Core\DB::Columns($table) as $col) {   // driver-aware
                if ((string) ($col['Field'] ?? '') === $pk) {
                    $extra = strtolower((string) ($col['Extra'] ?? ''));
                    $auto = str_contains($extra, 'auto_increment') || str_contains($extra, 'identity');
                    break;
                }
            }
        } catch (\Throwable) {
            // no DB → assume auto (safe: hides the key field rather than demanding input)
        }
        return $this->pkAutoCache[$table] = $auto;
    }

    /**
     * The shared "Set Filter" popup script (Excel-AutoFilter / AG-Grid style),
     * used by BOTH the toolbar Filter panel and the per-column header funnels.
     * A single delegated handler opens a themed popup on any `[data-pp-setfilter]`
     * trigger, loads that column's DISTINCT values from api/filter-values.php
     * (searchable, with counts + Select-all), pre-checks the current selection
     * from the URL, and applies as `f[col][]=…` → the list's existing IN() WHERE.
     * Written as a nowdoc so runtime `$`/backticks need no escaping.
     */
    private function setFilterScript(): string
    {
        $js = <<<'JS'
(function () {
  if (window.__ppSetFilterInit) return;
  window.__ppSetFilterInit = true;
  var API = (window.PP_BASE || '') + '/api/filter-values.php';
  var pop = null, trg = null, col = '', tbl = '', seq = 0, deb = null;

  function esc(s){ var d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
  function selectedFromUrl(c){
    var p=new URLSearchParams(location.search), o={};
    p.forEach(function(v,k){ if(k==='f['+c+'][]'||k==='f['+c+']') o[v]=true; });
    return o;
  }
  // A deferred trigger (toolbar) stores its picks as hidden inputs in the panel
  // form so the single Apply button commits every column at once; re-opening
  // reads those back. A header funnel has no form → reads the URL.
  function selectedFor(t, c){
    var form=t.closest('form');
    if(form){ var hid=form.querySelectorAll('input[data-sf="'+c+'"]'); if(hid.length){ var o={}; for(var i=0;i<hid.length;i++) o[hid[i].value]=true; return o; } }
    return selectedFromUrl(c);
  }
  function close(){
    if(pop){ pop.remove(); pop=null; }
    if(trg){ trg.setAttribute('aria-expanded','false'); }
    trg=null;
    document.removeEventListener('mousedown', onDoc, true);
    document.removeEventListener('keydown', onKey, true);
    window.removeEventListener('resize', close); window.removeEventListener('scroll', onScroll, true);
  }
  function onDoc(e){ if(pop && !pop.contains(e.target) && trg && !trg.contains(e.target)) close(); }
  function onKey(e){ if(e.key==='Escape') close(); }
  function onScroll(e){ if(pop && (!e.target || !pop.contains(e.target))) close(); }

  function applyValues(vals){
    var p=new URLSearchParams(location.search), keep=[];
    p.forEach(function(v,k){ if(k!=='f['+col+'][]' && k!=='f['+col+']') keep.push([k,v]); });
    var np=new URLSearchParams();
    keep.forEach(function(kv){ np.append(kv[0],kv[1]); });
    np.delete('page');
    vals.forEach(function(v){ np.append('f['+col+'][]', v); });
    location.search = np.toString();
  }
  // Commit the picks: a toolbar (deferred) trigger writes hidden form inputs and
  // updates its label without navigating — the panel Apply submits them all. A
  // header funnel navigates immediately (self-contained filter).
  function commit(vals){
    if(trg && trg.hasAttribute('data-sf-defer')){
      var form=trg.closest('form');
      if(form){
        form.querySelectorAll('input[data-sf="'+col+'"]').forEach(function(n){ n.remove(); });
        vals.forEach(function(v){ var i=document.createElement('input'); i.type='hidden'; i.name='f['+col+'][]'; i.value=v; i.setAttribute('data-sf',col); form.appendChild(i); });
        var tv=trg.querySelector('.pp-sf-tval'); if(tv){ var _sel=trg.getAttribute('data-sel-label')||'selected', _all=trg.getAttribute('data-all-label')||'All'; tv.textContent = vals.length ? (vals.length>2 ? (vals.length+' '+_sel) : vals.join(', ')) : _all; }
        trg.classList.toggle('is-active', vals.length>0);
      }
      close();
    } else { applyValues(vals); }
  }

  function fetchValues(q){
    var s=++seq;
    var list=pop.querySelector('.pp-sf-list');
    list.setAttribute('aria-busy','true');
    if(trg) trg.classList.add('is-loading');
    fetch(API+'?table='+encodeURIComponent(tbl)+'&column='+encodeURIComponent(col)+'&q='+encodeURIComponent(q||''),
      {credentials:'same-origin'})
      .then(function(r){ return r.json(); })
      .then(function(j){ if(trg) trg.classList.remove('is-loading'); if(s!==seq||!pop) return; renderList((j&&j.ok)?j.values:[], j&&j.more); })
      .catch(function(){ if(trg) trg.classList.remove('is-loading'); if(s===seq&&pop){ pop.querySelector('.pp-sf-list').innerHTML='<div class="pp-sf-empty">Could not load values.</div>'; } });
  }

  function renderList(values, more){
    var list=pop.querySelector('.pp-sf-list'); list.removeAttribute('aria-busy');
    if(!values.length){ list.innerHTML='<div class="pp-sf-empty">No values</div>'; syncSelAll(); return; }
    var sel=pop._sel||{};
    list.innerHTML = values.map(function(it){
      var v=it.v==null?'':String(it.v);
      var lbl=it.label!=null&&it.label!==''?it.label:(v===''?'(empty)':v);
      return '<label class="pp-sf-opt"><input type="checkbox" value="'+esc(v).replace(/"/g,'&quot;')+'"'+(sel[v]?' checked':'')+'>'
        + '<span class="pp-sf-lbl">'+esc(lbl)+'</span>'
        + '<span class="pp-sf-cnt">'+(it.count!=null?it.count:'')+'</span></label>';
    }).join('') + (more?'<div class="pp-sf-more">Type to narrow…</div>':'');
    list.querySelectorAll('input[type=checkbox]').forEach(function(c){ c.addEventListener('change', syncSelAll); });
    syncSelAll();
  }
  function syncSelAll(){
    var boxes=pop.querySelectorAll('.pp-sf-list input[type=checkbox]');
    var all=pop.querySelector('.pp-sf-selall'); if(!all) return;
    var on=0; boxes.forEach(function(c){ if(c.checked) on++; });
    all.checked = boxes.length>0 && on===boxes.length;
    all.indeterminate = on>0 && on<boxes.length;
  }

  function position(){
    var r=trg.getBoundingClientRect(), w=Math.min(300, Math.max(240, r.width));
    pop.style.minWidth=w+'px';
    var vw=document.documentElement.clientWidth;
    if(vw<=560){ pop.classList.add('pp-sf-sheet'); pop.style.left=''; pop.style.top=''; return; }
    var left=window.scrollX+r.left, top=window.scrollY+r.bottom+4;
    var pw=pop.offsetWidth||w;
    if(left+pw>window.scrollX+vw-8) left=window.scrollX+vw-pw-8;
    pop.style.left=Math.max(8,left)+'px'; pop.style.top=top+'px';
  }

  function open(t){
    close(); trg=t; tbl=t.getAttribute('data-table'); col=t.getAttribute('data-column');
    var label=t.getAttribute('data-label')||col;
    t.setAttribute('aria-expanded','true');
    pop=document.createElement('div'); pop.className='pp-setfilter-pop'; pop._sel=selectedFor(t, col);
    var T=function(k,d){ return (window.PP_SF_I18N||{})[k]||d; };
    pop.innerHTML =
      '<div class="pp-sf-head">'+esc(label)+'</div>'
      + '<div class="pp-sf-search"><input type="text" class="pp-sf-q" placeholder="'+esc(T('search','Search…'))+'" autocomplete="off" spellcheck="false"></div>'
      + '<label class="pp-sf-all"><input type="checkbox" class="pp-sf-selall"><span>'+esc(T('select_all','Select all'))+'</span></label>'
      + '<div class="pp-sf-list" role="listbox">'+esc(T('loading','Loading…'))+'</div>'
      + '<div class="pp-sf-foot"><button type="button" class="pp-sf-clear">'+esc(T('clear','Clear'))+'</button><span class="pp-sf-spacer"></span>'
      + '<button type="button" class="pp-sf-cancel">'+esc(T('cancel','Cancel'))+'</button><button type="button" class="pp-sf-ok">'+esc(T('apply','Apply'))+'</button></div>';
    document.body.appendChild(pop); position();
    if(t.hasAttribute('data-sf-defer')){ var okb=pop.querySelector('.pp-sf-ok'); if(okb) okb.textContent=T('ok','OK'); }
    var q=pop.querySelector('.pp-sf-q');
    q.addEventListener('input', function(){ clearTimeout(deb); deb=setTimeout(function(){ fetchValues(q.value); }, 220); });
    pop.querySelector('.pp-sf-selall').addEventListener('change', function(e){
      pop.querySelectorAll('.pp-sf-list input[type=checkbox]').forEach(function(c){ c.checked=e.target.checked; });
    });
    pop.querySelector('.pp-sf-cancel').addEventListener('click', close);
    pop.querySelector('.pp-sf-clear').addEventListener('click', function(){ commit([]); });
    pop.querySelector('.pp-sf-ok').addEventListener('click', function(){
      var vals=[]; pop.querySelectorAll('.pp-sf-list input[type=checkbox]:checked').forEach(function(c){ vals.push(c.value); });
      commit(vals);
    });
    fetchValues(''); try{ q.focus(); }catch(e){}
    document.addEventListener('mousedown', onDoc, true);
    document.addEventListener('keydown', onKey, true);
    window.addEventListener('resize', close); window.addEventListener('scroll', onScroll, true);
  }

  document.addEventListener('click', function(e){
    var t=e.target.closest('[data-pp-setfilter]');
    if(!t) return;
    e.preventDefault(); e.stopPropagation();
    if(trg===t) close(); else open(t);
  });
})();
JS;
        return "<script>\n" . $js . "\n</script>";
    }

    private function lookupFieldsFor(string $table): array
    {
        $out = [];
        // (1) Designed Add/Edit field components carrying the Lookup Wizard.
        foreach (['add', 'edit'] as $pageType) {
            foreach ($this->designFormFields($this->designFor($table, $pageType)) as $f) {
                if (($f['editControl'] ?? '') !== 'LookupWizard') continue;
                $entry = $this->lookupEntryFromSettings((string) ($f['field'] ?? ''), (array) ($f['editSettings'] ?? []));
                if ($entry !== null) $out[$f['field']] = $entry;
            }
        }
        // (2) Field-level Lookup Wizard assigned via the Field Controls modal
        // (editAs) — so "configure once, shown everywhere" works without ever
        // designing the page. A designed component above wins for the same field.
        foreach ((array) ($this->tableFields[$table] ?? []) as $tf) {
            $fn = (string) ($tf['name'] ?? '');
            if ($fn === '' || isset($out[$fn]) || ($tf['editAs'] ?? '') !== 'LookupWizard') continue;
            $entry = $this->lookupEntryFromSettings($fn, (array) ($tf['editAsSettings'] ?? []));
            if ($entry !== null) $out[$fn] = $entry;
        }
        return $out;
    }

    /**
     * Normalise one Lookup Wizard settings array into the display-mapping entry
     * used across list/view/print/export/filter/inline-edit. Returns null for a
     * manual-options lookup or an incomplete config (no table/display column).
     *
     * @param array<string,mixed> $s
     * @return array{table:string,key:string,display:string,where:string,orderBy:string,orderDir:string,unique:string,parentField:string,filterField:string,multiple:bool,preventDup:string}|null
     */
    private function lookupEntryFromSettings(string $field, array $s): ?array
    {
        if ($field === '') return null;
        $lt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['table'] ?? ''));
        $lk = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['linkField'] ?? 'id')) ?: 'id';
        $ld = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['displayField'] ?? ''));
        if ($lt === '' || $ld === '') return null;   // manual mode or incomplete
        // Optional designer-authored WHERE (option lists only — the key→display
        // mapping is unconditional). No statement chaining.
        $lw = trim(str_replace(["\0", ';'], '', (string) ($s['where'] ?? '')));
        $lw = preg_replace('/^(where|and)\s+/i', '', $lw) ?? $lw;
        return [
            'table' => $lt, 'key' => $lk, 'display' => $ld, 'where' => $lw,
            // Display preference: 'value' shows the raw stored key on list/view
            // instead of the display column (the mapping sites honour this).
            'displayAs'   => (($s['displayAs'] ?? 'label') === 'value') ? 'value' : 'label',
            'orderBy'     => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['orderBy'] ?? '')),
            'orderDir'    => strtoupper((string) ($s['orderDir'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC',
            'unique'      => ($s['unique'] ?? 'no') === 'yes' ? 'yes' : 'no',
            // Cascade: parent field on the MAIN table filters the options via
            // this column of the LOOKUP table.
            'parentField' => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['parentField'] ?? '')),
            'filterField' => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($s['filterField'] ?? '')),
            // Multi-value modes store CSV; duplicate prevention is enforced in
            // the generated save flow.
            'multiple'    => (($s['control'] ?? '') === 'checkboxes' || ($s['multiple'] ?? 'no') === 'yes'),
            'preventDup'  => ($s['preventDup'] ?? 'no') === 'yes' ? 'yes' : 'no',
        ];
    }

    /**
     * Static value→label maps for choice / toggle edit controls (Dropdown,
     * Radio Group, Button Toggle, Checkbox group, Switch, and a manual-mode
     * Lookup Wizard). These render the friendly label on list/view/print/export
     * instead of the stored code — the option-list analogue of lookupFieldsFor.
     * A field whose control is set to "Show … value" is skipped (raw value),
     * as is an identity map (label === value everywhere → nothing to translate).
     * Table-mode Lookup Wizard fields are NOT here — lookupFieldsFor owns those.
     *
     * @return array<string, array<string,string>>  field => [storedValue => label]
     */
    private function optionMapsFor(string $table): array
    {
        $out = [];
        $add = function (string $field, string $ctl, array $s) use (&$out): void {
            if ($field === '' || isset($out[$field])) return;
            if (($s['displayAs'] ?? 'label') === 'value') return;      // user wants the raw value
            $map = $this->optionValueLabelMap($ctl, $s);
            // Skip an identity map — nothing to translate, only page bloat.
            $meaningful = false;
            foreach ($map as $k => $v) { if ((string) $k !== (string) $v) { $meaningful = true; break; } }
            if ($meaningful) $out[$field] = $map;
        };
        foreach (['add', 'edit'] as $pageType) {
            foreach ($this->designFormFields($this->designFor($table, $pageType)) as $f) {
                $add((string) ($f['field'] ?? ''), (string) ($f['editControl'] ?? ''), (array) ($f['editSettings'] ?? []));
            }
        }
        foreach ((array) ($this->tableFields[$table] ?? []) as $tf) {
            $add((string) ($tf['name'] ?? ''), (string) ($tf['editAs'] ?? ''), (array) ($tf['editAsSettings'] ?? []));
        }
        return $out;
    }

    /**
     * Resolve one choice/toggle control's settings to a value→label map.
     *
     * @param array<string,mixed> $s
     * @return array<string,string>
     */
    private function optionValueLabelMap(string $ctl, array $s): array
    {
        switch ($ctl) {
            case 'Dropdown':
            case 'RadioGroup':
            case 'ButtonToggle':
                return \PpPhp\Core\Designer\FieldControl\LookupOptions::resolve($s);
            case 'Checkbox':
                if (($s['mode'] ?? 'single') === 'group') {
                    return \PpPhp\Core\Designer\FieldControl\LookupOptions::resolve($s);
                }
                // Single checkbox: only the checked state carries a label.
                $on = (string) ($s['onValue'] ?? '1');
                $onL = trim((string) ($s['text'] ?? ''));
                return $onL !== '' ? [$on => $onL] : [];
            case 'Switch':
                $on = (string) ($s['onValue'] ?? '1'); $off = (string) ($s['offValue'] ?? '0');
                $onL = trim((string) ($s['onText'] ?? '')); $offL = trim((string) ($s['offText'] ?? ''));
                $m = [];
                if ($onL !== '')  $m[$on]  = $onL;
                if ($offL !== '') $m[$off] = $offL;
                return $m;
            case 'LookupWizard':
                // Table mode is owned by lookupFieldsFor; only manual options here.
                if (($s['source'] ?? 'table') === 'table') return [];
                return \PpPhp\Core\Designer\FieldControl\LookupOptions::manualOptions((string) ($s['manualOptions'] ?? ''));
        }
        return [];
    }

    /**
     * Field names on a table that carry a `unique` validation rule — from the
     * designed Add/Edit forms and the field-level Field Controls modal. Drives
     * the async availability check (api/validate-unique.php).
     *
     * @return array<int,string>
     */
    private function uniqueFieldsFor(string $table): array
    {
        $out = [];
        $has = static function (mixed $rules): bool {
            foreach ((array) $rules as $r) {
                if (is_array($r) && (string) ($r['type'] ?? '') === 'unique') return true;
            }
            return false;
        };
        foreach (['add', 'edit'] as $pageType) {
            foreach ($this->designFormFields($this->designFor($table, $pageType)) as $f) {
                $fn = (string) ($f['field'] ?? '');
                if ($fn !== '' && !isset($out[$fn]) && $has($f['validations'] ?? [])) $out[$fn] = true;
            }
        }
        foreach ((array) ($this->tableFields[$table] ?? []) as $tf) {
            $fn = (string) ($tf['name'] ?? '');
            if ($fn !== '' && !isset($out[$fn]) && $has($tf['validations'] ?? [])) $out[$fn] = true;
        }
        return array_keys($out);
    }

    /**
     * Smart default View control from the field's type + name — mirrors the
     * Designer's controlDefaultsFor() so old designs without explicit
     * controls still render sensibly instead of falling back to plain text.
     */
    private function typeViewControl(string $type, string $name): string
    {
        $t = strtolower($type);
        $n = strtolower($name);
        if (str_contains($n, 'email')) return 'EmailHyperlink';
        if (str_contains($n, 'phone') || str_contains($n, 'mobile')) return 'PhoneNumber';
        if (str_contains($n, 'url') || str_contains($n, 'website') || str_contains($n, 'link')) return 'Hyperlink';
        if (str_contains($n, 'image') || str_contains($n, 'photo') || str_contains($n, 'avatar')) return 'Image';
        if (str_starts_with($t, 'datetime') || str_starts_with($t, 'timestamp')) return 'LongDate';
        if ($t === 'date' || str_starts_with($t, 'date')) return 'ShortDate';
        if ($t === 'currency' || str_starts_with($t, 'decimal') || str_starts_with($t, 'float') || str_starts_with($t, 'double')) return 'Number';
        return '';
    }

    /** Breadcrumb separator markup from the designed separator property. */
    private function crumbSeparator(array $props): string
    {
        $sep = trim((string) ($props['breadcrumb']['separator'] ?? '›'));
        if ($sep === '' || $sep === '›') {
            return '<i class="bi bi-chevron-right sep"></i>';
        }
        return '<span class="sep">' . htmlspecialchars($sep) . '</span>';
    }

    /** Page types enabled for a table (pages.json), defaulting to the core four. */
    private function pageTypesFor(string $table): array
    {
        $types = $this->ctx['pages'][$table]['pageTypes'] ?? null;
        return is_array($types) && $types !== [] ? $types : ['list', 'add', 'edit', 'view'];
    }

    /** Functionality flags for a table (pages.json). */
    private function functionalityFor(string $table): array
    {
        return (array) ($this->ctx['pages'][$table]['functionality'] ?? []);
    }

    /**
     * The formats a table may be exported in — the single authority for both the
     * toolbar menu and export.php's server-side guard, so the two can never
     * disagree. The Pages module DECIDES: exporting data is a security concern,
     * not just a UI preference.
     *
     * Returns [] meaning DENY when either the 'export' page type is not selected
     * or Export Settings were saved with every format unchecked. Only when the
     * settings were never opened at all do we fall back to the designed toolbar
     * prop (then CSV). DOC is the build's 'word' format.
     *
     * @return list<string>
     */
    private function allowedExportFormats(string $table, array $toolbar = []): array
    {
        // Page type not selected → export is not part of this app.
        if (!in_array('export', $this->pageTypesFor($table), true)) return [];

        $known = array_keys(\PpPhp\Core\DataOps\FormatRegistry::exports());
        $s = $this->ctx['pages'][$table]['settings']['export'] ?? null;

        if (is_array($s)) {
            // Preferred shape: settings.export.formats = ['csv','json',…] — a plain
            // list of registry ids, so a newly registered format needs no new key.
            if (isset($s['formats']) && is_array($s['formats'])) {
                return array_values(array_intersect(array_map('strval', $s['formats']), $known));
            }
            // Legacy per-format booleans (pre-registry projects).
            $out = [];
            foreach (['exportCsv' => 'csv', 'exportXlsx' => 'xlsx', 'exportDoc' => 'word'] as $key => $fmt) {
                if (!empty($s[$key])) $out[] = $fmt;
            }
            return array_values(array_intersect($out, $known));
        }

        // Never configured → the designed toolbar prop, else CSV.
        return array_values(array_intersect(
            array_map('strval', (array) ($toolbar['exportFormats'] ?? ['csv'])),
            $known
        )) ?: ['csv'];
    }

    /** Export option: emit the header row (pages.json → settings.export.includeHeaders). */
    private function exportIncludeHeaders(string $table): bool
    {
        return (($this->ctx['pages'][$table]['settings']['export']['includeHeaders'] ?? true) !== false);
    }

    /**
     * Flatten a designed Import page into an ordered list of blocks the runtime
     * knows how to render live.
     *
     * The Designer tree is a mock (a `tabs` node, a `field-mapper` node, …); we
     * translate it into [type, label] pairs so ImportPage can decide ORDER,
     * PRESENCE and headings from the design while still supplying the real,
     * stateful widget for each slot. Sections are walked so their children are
     * reachable, and hidden components are dropped.
     *
     * @return list<array{type:string,label:string}>
     */
    private function importDesignBlocks(array $design): array
    {
        $known = [
            'heading' => 'heading', 'text' => 'text', 'workflow-steps' => 'steps',
            'tabs' => 'source', 'upload' => 'source', 'form' => 'options',
            'field-mapper' => 'mapper', 'table-grid' => 'preview',
            'summary' => 'summary', 'buttons' => 'buttons',
        ];
        $blocks = [];
        // A section's heading titles the card its child slot renders into
        // ("1 · Import Source", "3 · Field Mapping", …).
        $walk = function (array $components, string $sectionLabel = '') use (&$walk, &$blocks, $known): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                if (($c['visibility']['visible'] ?? true) === false) continue;
                $type = (string) ($c['type'] ?? '');

                if ($type === 'section') {
                    if (is_array($c['children'] ?? null)) {
                        $walk($c['children'], trim((string) ($c['props']['heading'] ?? '')));
                    }
                    continue;
                }
                if (!isset($known[$type])) {
                    if (is_array($c['children'] ?? null)) $walk($c['children'], $sectionLabel);
                    continue;
                }

                $slot = $known[$type];
                // One block per slot — a design holding both `tabs` and `upload`
                // still yields a single source block (the runtime puts both
                // sources inside it).
                foreach ($blocks as $b) if ($b['type'] === $slot) continue 2;

                if ($slot === 'steps') {
                    $steps = array_values(array_filter(array_map('strval', (array) ($c['props']['steps'] ?? []))));
                    $blocks[] = ['type' => 'steps', 'label' => implode('|', $steps)];
                    continue;
                }
                $blocks[] = [
                    'type'  => $slot,
                    'label' => $sectionLabel !== '' ? $sectionLabel : trim((string) ($c['props']['text'] ?? '')),
                ];
            }
        };
        $walk((array) ($design['components'] ?? []));
        return $blocks;
    }

    /**
     * Flatten a designed Export page into ordered blocks (same contract as
     * importDesignBlocks): the design decides order/presence/labels, the runtime
     * supplies the live widget.
     *
     * @return list<array{type:string,label:string}>
     */
    private function exportDesignBlocks(array $design): array
    {
        $known = [
            'heading' => 'heading', 'text' => 'text', 'workflow-steps' => 'steps',
            'radio-group' => 'scope', 'field-selector' => 'fields',
            'format-selector' => 'format', 'table-grid' => 'preview',
            'summary' => 'summary', 'buttons' => 'buttons',
        ];
        $blocks = [];
        $walk = function (array $components, string $sectionLabel = '') use (&$walk, &$blocks, $known): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                if (($c['visibility']['visible'] ?? true) === false) continue;
                $type = (string) ($c['type'] ?? '');

                if ($type === 'section') {
                    if (is_array($c['children'] ?? null)) {
                        $walk($c['children'], trim((string) ($c['props']['heading'] ?? '')));
                    }
                    continue;
                }
                if (!isset($known[$type])) {
                    if (is_array($c['children'] ?? null)) $walk($c['children'], $sectionLabel);
                    continue;
                }
                $slot = $known[$type];
                foreach ($blocks as $b) if ($b['type'] === $slot) continue 2;

                if ($slot === 'steps') {
                    $steps = array_values(array_filter(array_map('strval', (array) ($c['props']['steps'] ?? []))));
                    $blocks[] = ['type' => 'steps', 'label' => implode('|', $steps)];
                    continue;
                }
                $blocks[] = [
                    'type'  => $slot,
                    'label' => $sectionLabel !== '' ? $sectionLabel : trim((string) ($c['props']['text'] ?? '')),
                ];
            }
        };
        $walk((array) ($design['components'] ?? []));
        return $blocks;
    }

    /** Key columns for a table (pages.json → keyColumns); used to match rows on import. */
    private function keyColumnsFor(string $table): array
    {
        return array_values(array_filter(array_map(
            'strval',
            (array) ($this->ctx['pages'][$table]['keyColumns'] ?? [])
        )));
    }

    /**
     * The file formats a table may be imported from (pages.json →
     * settings.import). Same contract as allowedExportFormats(): [] means DENY —
     * importing overwrites data, so the Pages module gates it. Never configured
     * → CSV only (what the import wizard has always shipped).
     *
     * @return list<string>
     */
    private function allowedImportFormats(string $table): array
    {
        if (!in_array('import', $this->pageTypesFor($table), true)) return [];

        $known = array_keys(\PpPhp\Core\DataOps\FormatRegistry::readers());
        $s = $this->ctx['pages'][$table]['settings']['import'] ?? null;
        if (!is_array($s)) return ['csv'];

        // Preferred shape: settings.import.readers = ['csv','json',…].
        if (isset($s['readers']) && is_array($s['readers'])) {
            return array_values(array_intersect(array_map('strval', $s['readers']), $known));
        }
        // Legacy booleans — importXls/importTxt were extensions handled by the
        // delimited reader, so both collapse onto 'csv'.
        $out = [];
        foreach (['importCsv' => 'csv', 'importXls' => 'csv', 'importTxt' => 'csv'] as $key => $fmt) {
            if (!empty($s[$key])) $out[$fmt] = true;
        }
        return array_values(array_intersect(array_keys($out), $known));
    }

    /**
     * Import wizard options (pages.json → settings.import). Passed straight to
     * ImportPage, so adding an option here surfaces it without touching the
     * generated code.
     *
     * @return array<string, mixed>
     */
    private function importOptionsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['import'] ?? []);
        $modes = array_values(array_intersect(
            array_map('strval', (array) ($s['modes'] ?? ['insert', 'skip', 'update'])),
            ['insert', 'skip', 'update']
        )) ?: ['insert'];

        // Default "match on": first key column that is importable, else blank
        // (ImportPage falls back to the first field). An AUTO_INCREMENT key is
        // never an upsert match target (the DB owns it) — skip the real pk when
        // auto, not a literal 'id'; a manual key can legitimately be the match.
        $match = '';
        $impNames = [];
        foreach ((array) ($this->ctx['fieldsByTable'][$table] ?? []) as $n) $impNames[$n] = true;
        $matchPk = $this->primaryKeyFor($table);
        $matchPkAuto = $this->primaryKeyIsAutoFor($table);
        foreach ($this->keyColumnsFor($table) as $kc) {
            if (($matchPkAuto && $kc === $matchPk)) continue;
            if ($impNames === [] || isset($impNames[$kc])) { $match = $kc; break; }
        }

        return [
            'allowPaste'       => ($s['allowPaste'] ?? true) !== false,
            'allowUpload'      => ($s['allowUpload'] ?? true) !== false,
            'delimiter'        => (string) ($s['delimiter'] ?? 'auto'),
            'modes'            => $modes,
            'matchField'       => (string) ($s['matchField'] ?? $match),
            'maxMb'            => max(1, (int) ($s['maxMb'] ?? 8)),
            'hasHeaderDefault' => ($s['hasHeaderDefault'] ?? true) !== false,
            'validateDefault'  => !empty($s['validateDefault']),
        ];
    }

    /**
     * PDF page settings for a table (pages.json → settings.pdf):
     * source = 'print' | 'view' (which layout the PDF is built from),
     * orientation = 'portrait' | 'landscape'.
     */
    /**
     * List-page runtime settings (pages.json → settings.list). The Pages module
     * exposes four toggles + Click Actions; the build wires each into the
     * generated list page. Unknown/legacy keys are ignored.
     */
    private function listSettingsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['list'] ?? []);
        $okType   = ['openPage', 'openUrl', 'selectRecord', 'expandDetails', 'executeCode'];
        // A click action may only open a page this table actually ships. Export
        // additionally honours Export Settings, so a row click can never reach an
        // endpoint the project disabled (it would 404 anyway — this keeps the UI
        // consistent by falling back to 'view').
        $types    = $this->pageTypesFor($table);
        $okTarget = array_values(array_filter(
            ['view', 'edit', 'add', 'print', 'export'],
            fn(string $t): bool => $t === 'export'
                ? $this->allowedExportFormats($table) !== []
                : in_array($t, $types, true)
        ));
        $okOpenIn  = ['current', 'popup', 'newtab'];
        $okOn      = ['single', 'double'];
        $okRefresh = ['none', 'row', 'grid', 'reload'];
        $clicks = [];
        foreach ((array) ($s['clickActions'] ?? []) as $ca) {
            if (!is_array($ca)) continue;
            $type = in_array(($ca['actionType'] ?? ''), $okType, true) ? $ca['actionType'] : 'openPage';
            $server = (string) ($ca['server'] ?? '');
            // Stable key for the executeCode Server (PHP) half. Author PHP is NEVER
            // shipped to the browser — it is baked into api/list-click.php keyed by
            // this id (table:index), which the client calls with CSRF (see the
            // executeCode branch + collectListClickHandlers). Empty when no server
            // code, so a purely client-side action makes no round trip.
            $srvKey = ($type === 'executeCode' && trim($server) !== '') ? ($table . ':' . count($clicks)) : '';
            // openUrl template — {field}/{id} tokens interpolated + URL-encoded on
            // the client. Reject javascript:/data: schemes (defence in depth even
            // though the author is trusted).
            $url = trim((string) ($ca['url'] ?? ''));
            if ($url !== '' && preg_match('/^\s*(javascript|data|vbscript):/i', $url)) $url = '';
            $clicks[] = [
                'field'        => (string) ($ca['field'] ?? 'row'),
                'actionType'   => $type,
                'on'           => in_array(($ca['on'] ?? ''), $okOn, true) ? $ca['on'] : 'single',
                'target'       => in_array(($ca['target'] ?? ''), $okTarget, true) ? $ca['target'] : 'view',
                'openIn'       => in_array(($ca['openIn'] ?? ''), $okOpenIn, true) ? $ca['openIn'] : 'current',
                'url'          => $url,
                'confirm'      => trim((string) ($ca['confirm'] ?? '')),
                'onlyWhen'     => trim((string) ($ca['onlyWhen'] ?? '')),
                'refresh'      => in_array(($ca['refresh'] ?? ''), $okRefresh, true) ? $ca['refresh'] : 'none',
                'clientBefore' => (string) ($ca['clientBefore'] ?? ''),
                'clientAfter'  => (string) ($ca['clientAfter'] ?? ''),
                'srvKey'       => $srvKey,
                // Server PHP — kept for round-trip + build-time baking ONLY; stripped
                // from the client payload (see $lsClicksJson) so it never leaks.
                'server'       => $server,
            ];
        }
        return [
            'scrollTable'      => !empty($s['scrollTable']),
            'reorderFields'    => !empty($s['reorderFields']),
            'resizableColumns' => !empty($s['resizableColumns']),
            'pinColumns'       => !empty($s['pinColumns']),
            'savedViews'       => !empty($s['savedViews']),
            'groupBy'          => (string) ($s['groupBy'] ?? ''),
            'kanbanField'      => (string) ($s['kanbanField'] ?? ''),
            'clickActions'     => $clicks,
        ];
    }

    /**
     * Additional WHERE tabs for a table's list page (Query designer → Additional
     * WHERE). Each is a developer-authored filter tab: a display name, a raw SQL
     * WHERE clause (trusted — the same model as the Events custom WHERE) and an
     * optional record-count badge. Empty when none are defined; only the list
     * page uses them.
     *
     * @return array<int,array{name:string,clause:string,showCount:bool}>
     */
    private function whereTabsFor(string $table): array
    {
        $out = [];
        foreach ((array) ($this->ctx['query'][$table]['whereTabs'] ?? []) as $t) {
            if (!is_array($t)) continue;
            // A "dynamic" tab auto-generates one tab per distinct value of a field
            // at request time (e.g. status → Active / Pending / Suspended). It is
            // resolved in the list-page runtime, not baked to fixed tabs, so new
            // values appear automatically. A "clause" tab is a fixed WHERE.
            if (($t['type'] ?? '') === 'dynamic') {
                $field = trim((string) ($t['field'] ?? ''));
                if ($field === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $field)) continue;
                $out[] = [
                    'type'      => 'dynamic',
                    'field'     => $field,
                    'label'     => trim((string) ($t['label'] ?? '')),
                    'showCount' => !empty($t['showCount']),
                    'hideEmpty' => !empty($t['hideEmpty']),
                ];
                continue;
            }
            $name   = trim((string) ($t['name'] ?? ''));
            $clause = trim((string) ($t['clause'] ?? ''));
            if ($name === '' || $clause === '') continue;
            $out[] = ['type' => 'clause', 'name' => $name, 'clause' => $clause, 'showCount' => !empty($t['showCount']), 'hideEmpty' => !empty($t['hideEmpty'])];
        }
        return $out;
    }

    /**
     * Add-page settings (pages.json → settings.add). showInPopup opens the Add
     * form in a modal from the list; afterAdd controls the post-save redirect
     * (list / next / edit / view); closePopup = in popup mode just close + reload.
     */
    private function addSettingsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['add'] ?? []);
        $aa = (string) ($s['afterAdd'] ?? 'list');
        $after = in_array($aa, ['list', 'next', 'edit', 'view'], true) ? $aa : 'list';
        return [
            'showInPopup' => !empty($s['showInPopup']),
            'afterAdd'    => $after,
            'closePopup'  => !empty($s['closePopup']),
        ];
    }

    /** Edit-page settings (pages.json → settings.edit). */
    private function editSettingsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['edit'] ?? []);
        $ae = (string) ($s['afterEdit'] ?? 'list');
        $after = in_array($ae, ['list', 'stay', 'view', 'next', 'previous'], true) ? $ae : 'list';
        return [
            'showInPopup' => !empty($s['showInPopup']),
            'afterEdit'   => $after,
            'closePopup'  => !empty($s['closePopup']),
        ];
    }

    /** View-page settings (pages.json → settings.view). */
    private function viewSettingsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['view'] ?? []);
        return [
            'showInPopup'     => !empty($s['showInPopup']),
            'hideEmptyFields' => !empty($s['hideEmptyFields']),
        ];
    }

    private function pdfSettingsFor(string $table): array
    {
        $s = (array) ($this->ctx['pages'][$table]['settings']['pdf'] ?? []);
        $source = (string) ($s['source'] ?? '');
        if ($source !== 'print' && $source !== 'view') {
            // Fall back to the older booleans, then default to print.
            $source = (!empty($s['viewPage']) && empty($s['printPage'])) ? 'view' : 'print';
        }
        $orientation = ($s['orientation'] ?? 'portrait') === 'landscape' ? 'landscape' : 'portrait';
        return ['source' => $source, 'orientation' => $orientation];
    }

    /**
     * Card template for a table (pages.json → settings.cards). Drives the
     * server-rendered Cards view. All keys optional; renderCards() derives a
     * sensible default (first list field = title, the rest = body) when the
     * template is empty, so cards work the moment the page-type is enabled.
     *
     *   title/subtitle/image/badge : column names (image = a URL/path column)
     *   badgeColors                : map of raw badge value → colour ('*' = any)
     *   body                       : ordered list of column names for the body
     *   showLabels                 : show field labels next to body values
     *   perRow                     : fixed columns per row (0 = responsive auto)
     *   actions                    : show the per-card view/edit/delete buttons
     */
    private function cardTemplateFor(string $table, array $tableFields = []): array
    {
        // Prefer the caller's resolved fields (always populated during page
        // generation); fall back to the shared map if a caller passes none.
        $srcFields = $tableFields !== [] ? $tableFields : (array) ($this->tableFields[$table] ?? []);
        // Primary source: a REAL per-table Designer Cards page (card-grid
        // component) — same authoring surface as every other page. Only a saved
        // per-table design wins (not the empty master template), so tables still
        // configured through the legacy Pages-module modal keep working.
        $slot = $this->ctx['designer']['pages'][$table]['cards'] ?? null;
        if (is_array($slot)) {
            $pages = (array) ($slot['pages'] ?? []);
            $name  = (string) ($slot['defaultPage'] ?? '');
            $page  = $pages[$name] ?? ($pages !== [] ? reset($pages) : null);
            if (is_array($page) && !empty($page['components'])) {
                $cg = $this->componentNodes($page)['card-grid'] ?? null;
                if (is_array($cg)) {
                    $tpl = $this->cardTemplateFromGrid((array) ($cg['props'] ?? []));
                    // A PRISTINE cards page (auto-scaffolded, never hand-edited) follows
                    // the Fields module's current 'cards' choices, not a capped snapshot.
                    if (!empty($page['pristine'])) {
                        $promoted = array_filter([$tpl['title'] ?? '', $tpl['subtitle'] ?? '', $tpl['image'] ?? '', $tpl['badge'] ?? '']);
                        $body = [];
                        foreach ($srcFields as $f) {
                            $fn = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($f['name'] ?? ''));
                            if ($fn === '' || in_array($fn, $promoted, true)) continue;
                            if (($f['enabled'] ?? true) === false) continue;
                            if (($f['cards'] ?? true) === false) continue;
                            $body[] = $fn;
                        }
                        if ($body !== []) $tpl['body'] = $body;
                    }
                    return $tpl;
                }
            }
        }

        $s = (array) ($this->ctx['pages'][$table]['settings']['cards'] ?? []);
        $clean = static fn($v): string => preg_replace('/[^A-Za-z0-9_]/', '', (string) $v);
        $body = [];
        foreach ((array) ($s['body'] ?? []) as $b) {
            $b = $clean($b);
            if ($b !== '') $body[] = $b;
        }
        $colors = [];
        foreach ((array) ($s['badgeColors'] ?? []) as $k => $v) {
            $colors[(string) $k] = (string) $v;
        }
        return [
            'title'       => $clean($s['title'] ?? ''),
            'subtitle'    => $clean($s['subtitle'] ?? ''),
            'image'       => $clean($s['image'] ?? ''),
            'badge'       => $clean($s['badge'] ?? ''),
            'badgeColors' => $colors,
            'body'        => $body,
            'showLabels'  => ($s['showLabels'] ?? true) !== false,
            'perRow'      => max(0, min(6, (int) ($s['perRow'] ?? 0))),
            'actions'     => ($s['actions'] ?? true) !== false,
        ];
    }

    /**
     * Map a Designer card-grid component's props → the renderCards() template.
     *   titleField/subtitleField/imageField/badgeField → title/subtitle/image/badge
     *   columns (visible, minus the promoted header fields)               → body
     *   colsDesktop → perRow · showActions → actions · badgeColors/showLabels pass through
     */
    private function cardTemplateFromGrid(array $p): array
    {
        $clean = static fn($v): string => preg_replace('/[^A-Za-z0-9_]/', '', (string) $v);
        $title    = $clean($p['titleField'] ?? '');
        $subtitle = $clean($p['subtitleField'] ?? '');
        $image    = $clean($p['imageField'] ?? '');
        $badge    = $clean($p['badgeField'] ?? '');
        $promoted = [$title, $subtitle, $image, $badge];

        $body = [];
        foreach ((array) ($p['columns'] ?? []) as $col) {
            if (!is_array($col)) continue;
            if (($col['visible'] ?? true) === false) continue;
            $f = $clean($col['field'] ?? '');
            if ($f === '' || in_array($f, $promoted, true)) continue;
            $body[] = $f;
        }

        $colors = [];
        foreach ((array) ($p['badgeColors'] ?? []) as $k => $v) {
            $colors[(string) $k] = (string) $v;
        }

        // Per-part visual styling designed in the Designer (card header/title/
        // badge/body/footer …). Sanitised into a {part: {prop: value}} map.
        $partStyles = [];
        $allowedParts = ['cbox', 'cmedia', 'chead', 'ctitle', 'csub', 'cbadge', 'cbody', 'cfoot'];
        foreach ((array) ($p['partStyles'] ?? []) as $part => $st) {
            $part = (string) $part;
            // accept the fixed parts plus per-action buttons (cact:<action>)
            $isAction = strpos($part, 'cact:') === 0 && preg_match('/^cact:[a-z-]+$/', $part);
            if ((!in_array($part, $allowedParts, true) && !$isAction) || !is_array($st)) continue;
            $clean = [];
            foreach ($st as $k => $v) {
                if (!is_string($k) || $v === '' || $v === null) continue;
                $clean[$k] = (string) $v;
            }
            if ($clean !== []) $partStyles[(string) $part] = $clean;
        }

        // Conditional formatting rules — {field, op, value, target, background,
        // color, bold} evaluated per record at runtime by renderCards.
        $cardRules = [];
        $okTargets = ['cbox', 'chead', 'ctitle', 'csub', 'cbadge', 'cbody', 'cfoot'];
        $okOps = ['=', '!=', '>', '<', '>=', '<=', 'contains', 'empty', 'not empty'];
        foreach ((array) ($p['cardRules'] ?? []) as $r) {
            if (!is_array($r)) continue;
            $rf = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['field'] ?? ''));
            if ($rf === '') continue;
            $cardRules[] = [
                'field'      => $rf,
                'op'         => in_array(($r['op'] ?? '='), $okOps, true) ? (string) $r['op'] : '=',
                'value'      => (string) ($r['value'] ?? ''),
                'target'     => in_array(($r['target'] ?? 'cbox'), $okTargets, true) ? (string) $r['target'] : 'cbox',
                'background' => (string) ($r['background'] ?? ''),
                'color'      => (string) ($r['color'] ?? ''),
                'bold'       => !empty($r['bold']),
            ];
        }

        return [
            'title'       => $title,
            'subtitle'    => $subtitle,
            'image'       => $image,
            'badge'       => $badge,
            'badgeColors' => $colors,
            'body'        => $body,
            'showLabels'  => ($p['showLabels'] ?? true) !== false,
            'perRow'      => max(0, min(6, (int) ($p['colsDesktop'] ?? 0))),
            'density'     => (string) ($p['density'] ?? 'normal'),
            'actions'     => ($p['showActions'] ?? true) !== false,
            'partStyles'  => $partStyles,
            'cardRules'   => $cardRules,
        ];
    }

    /** Per-table list options from the Misc module. */
    private function miscFor(string $table): array
    {
        return (array) ($this->ctx['misc'][$table] ?? []);
    }

    /** All valid Master–Detail relations from the Tables module (Relation modal). */
    private function relationsAll(): array
    {
        $out = [];
        foreach ((array) ($this->ctx['relations'] ?? []) as $r) {
            if (!is_array($r)) continue;
            $mt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['master_table'] ?? ''));
            $dt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['detail_table'] ?? ''));
            $df = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['detail_field'] ?? ''));
            $mf = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['master_field'] ?? '')) ?: 'id';
            if ($mt === '' || $dt === '' || $df === '') continue;
            $r['master_table'] = $mt; $r['detail_table'] = $dt;
            $r['detail_field'] = $df; $r['master_field'] = $mf;
            $r['id'] = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($r['id'] ?? '')) ?: ('rel_' . substr(md5("$mt.$dt.$df"), 0, 8));
            $out[] = $r;
        }
        return $out;
    }

    /** Relations that show detail records on this master table's given page, in display order. */
    private function relationsForMaster(string $table, string $page): array
    {
        $rels = array_values(array_filter($this->relationsAll(), static fn ($r) =>
            $r['master_table'] === $table && in_array($page, (array) ($r['master_pages'] ?? []), true)));
        usort($rels, static fn ($a, $b) => (int) ($a['display_order'] ?? 0) <=> (int) ($b['display_order'] ?? 0));
        return $rels;
    }

    /** The Details-column config (props.detailsCol) from a table's list design. */
    private function detailsColFor(string $masterTable): array
    {
        $design = $this->designFor($masterTable, 'list');
        $found = [];
        $scan = static function (array $comps) use (&$scan, &$found): void {
            foreach ($comps as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'table-grid' && isset($c['props']['detailsCol'])) {
                    $found = (array) $c['props']['detailsCol'];
                    return;
                }
                if (!empty($c['children'])) $scan((array) $c['children']);
            }
        };
        if (!empty($design['components'])) $scan((array) $design['components']);
        return $found;
    }

    /** Relations that show the master record on this detail table's given page. */
    private function relationsForDetail(string $table, string $page): array
    {
        return array_values(array_filter($this->relationsAll(), static fn ($r) =>
            $r['detail_table'] === $table
            && !empty($r['show_master_on_detail'])
            && in_array($page, (array) ($r['detail_pages'] ?? []), true)));
    }

    /**
     * Auto-rendered detail sections for a master page (view/edit/print),
     * honoring the relation's Display option: inline = embedded grid, popup =
     * button opening the grid in a dialog, icon = compact count link to the
     * filtered detail list. Relations already covered by a hand-placed
     * detail-grid in the page's design are skipped.
     */
    private function relationGridsHtml(string $tableName, string $page): string
    {
        $rels = $this->relationsForMaster($tableName, $page);
        if ($rels === []) return '';

        // designed detail-grids on this page already cover these table+FK pairs
        $covered = [];
        $design = $this->designFor($tableName, $page);
        $scan = static function (array $comps) use (&$scan, &$covered): void {
            foreach ($comps as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'detail-grid') {
                    $covered[(string) (($c['props']['table'] ?? '')) . '|' . (string) (($c['props']['fkColumn'] ?? ''))] = true;
                }
                if (!empty($c['children'])) $scan((array) $c['children']);
            }
        };
        if (!empty($design['components'])) $scan((array) $design['components']);
        // Per-relation styling designed on the master's view/edit CANVAS
        // (page.relationStyles[relId] → section/header/grid overrides).
        $relStyles = (array) ($design['relationStyles'] ?? []);

        $html = '';
        foreach ($rels as $r) {
            if (isset($covered[$r['detail_table'] . '|' . $r['detail_field']])) continue;
            $mode = (string) ($r['display_option'] ?? 'inline');
            $label = ucwords(str_replace(['-', '_'], ' ', $r['detail_table']));
            $dt = $r['detail_table']; $df = $r['detail_field']; $mf = $r['master_field'];
            $rid = $r['id'];
            $rs = (array) ($relStyles[$rid] ?? []);
            $v = '$__rel' . substr(md5($rid . $page), 0, 6);
            if ($page === 'print' || $mode === 'inline') {
                $isPrint = ($page === 'print');
                $html .= ComponentRenderer::render([
                    'id' => $rid, 'type' => 'detail-grid',
                    'props' => [
                        // Empty (not $label) when the designer set no custom title, so
                        // the detail-grid renderer resolves the runtime table caption
                        // (Misc caption + active language) instead of a static English name.
                        'title'       => (string) ($rs['title'] ?? ''),
                        'table'       => $dt,
                        'fkColumn'    => $df,
                        'masterKey'   => $mf,
                        'allowAdd'    => !$isPrint,
                        'allowView'   => !$isPrint,
                        'allowEdit'   => !$isPrint,
                        'allowDelete' => false,
                        'showViewAll' => !$isPrint && !empty($r['show_proceed_link']),
                        'hideIfEmpty' => !empty($r['hide_if_empty']),
                        // designer canvas styling overrides (detailSectionCss keys)
                        'bg'          => $rs['bg'] ?? '',
                        'border'      => $rs['border'] ?? '',
                        'radius'      => $rs['radius'] ?? '',
                        'pad'         => $rs['pad'] ?? '',
                        'icon'        => trim((string) ($rs['headerIcon'] ?? '')) !== '' ? $rs['headerIcon'] : 'bi-diagram-3',
                        'headerBg'    => $rs['headerBg'] ?? '',
                        'headerColor' => $rs['headerColor'] ?? '',
                        'hideHeader'  => !empty($rs['hideHeader']),
                        'striped'     => !empty($rs['striped']),
                        'compact'     => !empty($rs['compact']),
                        'hover'       => !empty($rs['hover']),
                        // header-component styling + designed columns (same
                        // keys as the detail-grid component editors)
                        'titleColor'  => $rs['titleColor'] ?? '',
                        'titleBold'   => !empty($rs['titleBold']),
                        'badgeHide'   => !empty($rs['badgeHide']),
                        'badgeSize'   => $rs['badgeSize'] ?? '',
                        'badgeShape'  => $rs['badgeShape'] ?? '',
                        'badgeBg'     => $rs['badgeBg'] ?? '',
                        'badgeColor'  => $rs['badgeColor'] ?? '',
                        'addHide'     => !empty($rs['addHide']),
                        'addLabel'    => $rs['addLabel'] ?? '',
                        'addIcon'     => $rs['addIcon'] ?? '',
                        'addBg'       => $rs['addBg'] ?? '',
                        'addColor'    => $rs['addColor'] ?? '',
                        'goHide'      => !empty($rs['goHide']),
                        'goLabel'     => $rs['goLabel'] ?? '',
                        'goIcon'      => $rs['goIcon'] ?? '',
                        'goBg'        => $rs['goBg'] ?? '',
                        'goColor'     => $rs['goColor'] ?? '',
                        'columnsCfg'  => (array) ($rs['cols'] ?? []),
                        'pageSize'    => (int) (($rs['limit'] ?? 0) ?: 10),
                        'orderDir'    => ($rs['orderDir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC',
                    ],
                ], static fn (): string => '') . "\n";
            } elseif ($mode === 'popup') {
                $html .= "<?php {$v}K = (string) (\$row['$mf'] ?? ''); {$v}N = {$v}K === '' ? 0 : (int) \\PpPhp\\Core\\DB::FetchOne(\"SELECT COUNT(*) FROM `$dt` WHERE `$df` = ?\", [{$v}K]); ?>"
                    . (!empty($r['hide_if_empty']) ? "<?php if ({$v}N > 0): ?>" : '')
                    . "<?php if ({$v}K !== ''): ?><button type=\"button\" class=\"ppd-grid-toolbar-btn\" style=\"margin-top:.75rem;\" data-pp-md-popup=\"$rid\" data-pp-md-key=\"<?= htmlspecialchars({$v}K) ?>\">"
                    . '<i class="bi bi-diagram-3"></i> ' . self::eStatic($label) . " (<?= {$v}N ?>)</button><?php endif; ?>"
                    . (!empty($r['hide_if_empty']) ? '<?php endif; ?>' : '') . "\n";
            } else {   // icon
                $html .= "<?php {$v}K = (string) (\$row['$mf'] ?? ''); {$v}N = {$v}K === '' ? 0 : (int) \\PpPhp\\Core\\DB::FetchOne(\"SELECT COUNT(*) FROM `$dt` WHERE `$df` = ?\", [{$v}K]); ?>"
                    . (!empty($r['hide_if_empty']) ? "<?php if ({$v}N > 0): ?>" : '')
                    . "<?php if ({$v}K !== ''): ?><a class=\"ppd-grid-toolbar-btn\" style=\"margin-top:.75rem;display:inline-flex;\" title=\"" . self::eStatic($label) . "\" href=\"<?= APP_BASE ?>/generated/$dt/list.php?f%5B$df%5D=<?= urlencode({$v}K) ?>\">"
                    . "<i class=\"bi bi-diagram-3\"></i> <span class=\"badge bg-secondary\"><?= {$v}N ?></span></a><?php endif; ?>"
                    . (!empty($r['hide_if_empty']) ? '<?php endif; ?>' : '') . "\n";
            }
        }
        return $html;
    }

    /**
     * "Master record" card for a detail page (Relation modal → For Details
     * Table): a compact summary of the parent row + link to its view page.
     */
    private function relationMasterCardHtml(string $tableName, string $page): string
    {
        $html = '';
        foreach ($this->relationsForDetail($tableName, $page) as $r) {
            $mt = $r['master_table']; $mf = $r['master_field']; $df = $r['detail_field'];
            $label = ucwords(str_replace(['-', '_'], ' ', $mt));
            $v = '$__mst' . substr(md5($r['id'] . $page), 0, 6);
            $html .= "<?php {$v}K = (string) (\$row['$df'] ?? ''); {$v} = {$v}K === '' ? null : (\\PpPhp\\Core\\DB::FetchAll(\"SELECT * FROM `$mt` WHERE `$mf` = ? LIMIT 1\", [{$v}K])[0] ?? null); ?>"
                . "<?php if ({$v}): ?><div class=\"pp-master-card\" style=\"border:1px solid var(--pp-border,#e2e8f0);border-left:3px solid var(--pp-primary,#2563eb);border-radius:12px;background:var(--pp-surface,#fff);padding:12px 14px;margin-bottom:1rem;display:flex;align-items:center;gap:14px;flex-wrap:wrap;\">"
                . '<strong style="font-size:.9rem;"><i class="bi bi-box-arrow-in-up-left"></i> ' . self::eStatic($label) . '</strong>'
                . "<?php \$__i = 0; foreach ({$v} as \$__mc => \$__mv) { if (\$__i >= 4 || !is_scalar(\$__mv)) continue; \$__i++;"
                . " echo '<span style=\"color:var(--pp-muted,#64748b);font-size:.85rem;\">' . htmlspecialchars(ucwords(str_replace('_', ' ', \$__mc))) . ': <strong style=\"color:inherit;\">' . htmlspecialchars((string) \$__mv) . '</strong></span>'; } ?>"
                . '<span style="flex:1"></span>'
                . "<a class=\"ppd-grid-toolbar-btn\" href=\"<?= APP_BASE ?>/generated/$mt/view.php?id=<?= urlencode({$v}K) ?>\"><i class=\"bi bi-eye\"></i> <?= pp_t('ui.view', 'View') ?></a>"
                . '</div><?php endif; ?>' . "\n";
        }
        return $html;
    }

    /** htmlspecialchars for build-time strings (static context helper). */
    private static function eStatic(string $v): string
    {
        return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
    }

    public function writeManifest(BuildManifest $manifest, string $outputDir): array
    {
        $this->log('Writing ' . count($manifest->artifacts()) . ' artifacts…');

        foreach ($manifest->artifacts() as $artifact) {
            $path = $outputDir . '/' . $artifact->path();
            $dir = dirname($path);

            if (!is_dir($dir)) {
                mkdir($dir, 0755, true);
                $this->log("Created directory: $dir");
            }

            $bytes = file_put_contents($path, $artifact->contents());
            if ($bytes === false) {
                $this->log("FAILED to write: $path");
            } else {
                $this->log("Wrote {$artifact->path()} ({$bytes} bytes)");
                $this->filesWritten[] = $path;
            }
        }

        return $this->filesWritten;
    }

    /**
     * Rebuild manifest checksums from the final files on disk.
     *
     * The first Designer pass creates structural artifacts; the PHP runtime
     * generators intentionally replace those files with functional versions.
     */
    public function refreshManifest(BuildManifest $manifest, string $outputDir): BuildManifest
    {
        $artifacts = [];
        foreach ($manifest->artifacts() as $artifact) {
            if ($artifact->path() === 'manifest.json') {
                continue;
            }
            $path = $outputDir . '/' . $artifact->path();
            if (!is_file($path)) {
                continue;
            }
            $contents = file_get_contents($path);
            if ($contents === false) {
                continue;
            }
            $row = $artifact->toArray();
            $artifacts[] = BuildArtifact::create(
                $artifact->id(),
                $artifact->type(),
                $artifact->path(),
                $contents,
                (array) ($row['meta'] ?? [])
            );
        }

        $base = BuildManifest::create(
            $manifest->applicationId(),
            $artifacts,
            $manifest->routes(),
            $manifest->assets(),
            ['source' => 'final-generated-output', 'refreshedAt' => date('c')]
        );
        $manifestArtifact = $base->manifestArtifact();
        $manifestPath = $outputDir . '/' . $manifestArtifact->path();
        file_put_contents($manifestPath, $manifestArtifact->contents());
        $this->filesWritten[] = $manifestPath;
        $this->log('Refreshed manifest.json from final generated files');

        return $base->withManifestArtifact();
    }

    /**
     * SEO slug routes for the generated front controller (feature A1). A page
     * opts in via its `seo` setting:
     *   settings.seo = { enabled:true, prefix:'q', slug_column:'slug',
     *                    title_column:'title', id_column:'id', numeric_id:true }
     * Returns [] when nothing opts in, so the front controller no-ops and the
     * build behaves exactly as before. All identifiers are sanitised here (they
     * are interpolated into SQL/paths in the runtime), prefixes de-duplicated
     * (first page wins), and a slug source (slug_column or title_column) required.
     *
     * @return array<string,array<string,mixed>>
     */
    private function seoRoutesConfig(): array
    {
        $ident = static fn (string $s): bool => (bool) preg_match('/^[A-Za-z0-9_]+$/', $s);
        $routes = [];
        foreach ((array) ($this->ctx['pages'] ?? []) as $table => $page) {
            $seo = is_array($page) ? ($page['settings']['view']['seo'] ?? null) : null;
            if (!is_array($seo) || empty($seo['enabled'])) {
                continue;
            }
            $table = (string) $table;
            if (!$ident($table)) {
                continue;
            }
            $prefix = preg_replace('/[^a-z0-9-]/', '', strtolower(trim((string) ($seo['prefix'] ?? '')))) ?? '';
            if ($prefix === '' || isset($routes[$prefix])) {
                continue;                                  // need a prefix; first page to claim it wins
            }
            $slugRaw  = (string) ($seo['slug_column'] ?? '');
            $titleRaw = (string) ($seo['title_column'] ?? '');
            $idRaw    = (string) ($seo['id_column'] ?? 'id');
            $slugCol  = $ident($slugRaw) ? $slugRaw : '';
            $titleCol = $ident($titleRaw) ? $titleRaw : '';
            $idCol    = $ident($idRaw) ? $idRaw : 'id';
            if ($slugCol === '' && $titleCol === '') {
                continue;                                  // no way to derive the canonical slug
            }
            $routes[$prefix] = [
                'table'        => $table,
                'file'         => '/generated/' . $table . '/view.php',
                'id_param'     => 'id',
                'id_column'    => $idCol,
                'slug_column'  => $slugCol,
                'title_column' => $titleCol,
                'numeric_id'   => ($seo['numeric_id'] ?? true) ? true : false,
            ];
        }
        return $routes;
    }

    /**
     * Per-record SEO meta config for one page (feature A2), or [] when the page
     * hasn't opted in. Consumed at runtime by the generated view page via
     * config/seo-meta.php + \PpPhp\Core\Seo\MetaTags::build(). Shares the page's
     * `seo` setting with A1; identifiers are sanitised, free-text templates pass
     * through (MetaTags escapes them into the HTML).
     *
     * @return array<string,mixed>
     */
    private function seoMetaConfig(string $table): array
    {
        $seo = is_array($this->ctx['pages'][$table]['settings']['view']['seo'] ?? null)
            ? $this->ctx['pages'][$table]['settings']['view']['seo'] : null;
        if (!is_array($seo) || empty($seo['enabled'])) {
            return [];
        }
        $col = static fn (mixed $v): string => preg_match('/^[A-Za-z0-9_]+$/', (string) $v) ? (string) $v : '';
        $freq = strtolower(trim((string) ($seo['changefreq'] ?? '')));
        $freq = in_array($freq, ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'], true) ? $freq : '';
        $prio = (string) ($seo['priority'] ?? '');
        $prio = is_numeric($prio) ? (string) max(0.0, min(1.0, (float) $prio)) : '';
        return [
            'prefix'               => preg_replace('/[^a-z0-9-]/', '', strtolower(trim((string) ($seo['prefix'] ?? '')))) ?? '',
            'id_column'            => $col($seo['id_column'] ?? 'id') !== '' ? $col($seo['id_column'] ?? 'id') : 'id',
            'slug_column'          => $col($seo['slug_column'] ?? ''),
            'title_column'         => $col($seo['title_column'] ?? ''),
            'title_template'       => (string) ($seo['title_template'] ?? ''),
            'description_field'    => $col($seo['description_field'] ?? ''),
            'description_template' => (string) ($seo['description_template'] ?? ''),
            'og_image_field'       => $col($seo['og_image_field'] ?? ''),
            'og_image_default'     => (string) ($seo['og_image_default'] ?? ''),
            'og_type'              => (string) ($seo['og_type'] ?? 'article'),
            'robots'               => ((string) ($seo['robots'] ?? 'index')) === 'noindex' ? 'noindex' : 'index',
            'lastmod_column'       => $col($seo['lastmod_column'] ?? ''),
            'changefreq'           => $freq,
            'priority'             => $prio,
        ];
    }

    /**
     * All pages' SEO meta configs keyed by table (feature A2), for
     * config/seo-meta.php. Empty when nothing opts in.
     *
     * @return array<string,array<string,mixed>>
     */
    private function seoMetaConfigAll(): array
    {
        $out = [];
        foreach ((array) ($this->ctx['pages'] ?? []) as $table => $page) {
            $table = (string) $table;
            if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
                continue;
            }
            $cfg = $this->seoMetaConfig($table);
            if ($cfg !== []) {
                $out[$table] = $cfg;
            }
        }
        return $out;
    }

    /**
     * Whether a table's list/view pages are publicly readable without login
     * (feature A8). Only honoured when SEO is enabled for the page, so a table
     * can't be exposed by accident — it must be a deliberately crawlable page.
     */
    private function publicReadFor(string $table): bool
    {
        $seo = $this->ctx['pages'][$table]['settings']['view']['seo'] ?? null;
        return is_array($seo) && !empty($seo['enabled']) && !empty($seo['public_read']);
    }

    public function generateBootstrap(string $outputDir, string $slug, array $outputConfig, array $dbConfig): array
    {
        $this->log('Generating bootstrap files…');
        $written = [];

        $configDir = $outputDir . '/config';
        if (!is_dir($configDir)) mkdir($configDir, 0755, true);

        // ── config/database.php (or standalone marker) ──
        if (\PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug)) {
            // Database-free app: no DB profile. Drop a marker the generated runtime
            // uses to skip the connection and run public (no auth). Remove any stale
            // database.php from an earlier DB-backed build into the same folder.
            @unlink($configDir . '/database.php');
            @unlink($configDir . '/installed.lock');
            $flag = $configDir . '/standalone.php';
            file_put_contents($flag, "<?php\n\ndeclare(strict_types=1);\n\n// This app has no database — it is built entirely from Designer pages.\nreturn ['standalone' => true];\n");
            $this->log('Wrote config/standalone.php (database-free app)');
            $written[] = $flag;
        } else {
            @unlink($configDir . '/standalone.php');   // no longer a standalone build
            $dbProfile = $dbConfig;
            $dbProfile['type'] = $dbProfile['type'] ?? 'mysql';
            $dbProfile['dbname'] = $dbProfile['dbname'] ?? $dbProfile['name'] ?? $slug;
            $dbProfile['charset'] = $dbProfile['charset'] ?? 'utf8mb4';
            unset($dbProfile['name']);

            if ($this->standaloneInstaller) {
                // Installer mode: ship NO real credentials. install.php collects them at
                // runtime and writes config/database.php then. We leave a documented
                // sample so operators can also configure by hand if they prefer.
                @unlink($configDir . '/database.php');
                @unlink($configDir . '/installed.lock');
                $sample = $dbProfile;
                $sample['host'] = 'localhost';
                $sample['user'] = 'YOUR_DB_USER';
                $sample['pass'] = 'YOUR_DB_PASSWORD';
                $sampleFile = $configDir . '/database.sample.php';
                file_put_contents(
                    $sampleFile,
                    "<?php\n\ndeclare(strict_types=1);\n\n"
                    . "// Sample database profile. The bundled install.php writes the real\n"
                    . "// config/database.php at install time. To configure by hand instead,\n"
                    . "// copy this file to database.php and fill in your credentials.\n\n"
                    . "return " . var_export($sample, true) . ";\n"
                );
                $this->log('Wrote config/database.sample.php (installer mode — no credentials baked)');
                $written[] = $sampleFile;
            } else {
                $dbFile = $configDir . '/database.php';
                file_put_contents($dbFile, "<?php\n\ndeclare(strict_types=1);\n\nreturn " . var_export($dbProfile, true) . ";\n");
                $this->log('Wrote config/database.php');
                $written[] = $dbFile;
            }
        }

        // ── config/app.php ──
        $baseUrl = $outputConfig['general']['baseUrl'] ?? '/';
        $this->outBaseUrl = (string) $baseUrl;   // for the standalone sitemap/robots
        $versionLabel = $outputConfig['advanced']['versionLabel'] ?? '1.0.0';
        $errorReporting = $outputConfig['advanced']['errorReporting'] ?? 'production';
        $lineEndings = $outputConfig['advanced']['lineEndings'] ?? 'lf';
        $appFile = $configDir . '/app.php';
        file_put_contents($appFile, '<?php
declare(strict_types=1);
return [
    \'name\' => ' . var_export($slug, true) . ',
    \'env\' => \'production\',
    \'debug\' => false,
    \'base_url\' => ' . var_export($baseUrl, true) . ',
    \'version\' => ' . var_export($versionLabel, true) . ',
    \'error_reporting\' => ' . var_export($errorReporting, true) . ',
    \'line_endings\' => ' . var_export($lineEndings, true) . ',
    \'generated_at\' => \'' . date('c') . '\',
];
');
        $this->log('Wrote config/app.php');
        $written[] = $appFile;

        // ── config/auth.php ── the authentication table + logical→physical column
        // map, read by core/Auth/UserSchema so the whole auth stack targets the
        // developer's chosen table. Written whenever Database authentication is on;
        // when absent, UserSchema falls back to the standard `users` schema.
        $sec = (array) ($this->ctx['security'] ?? []);
        if (!empty($sec['db_auth_enabled'])) {
            $ident = static fn(string $v, string $d): string => (preg_replace('/[^A-Za-z0-9_]/', '', $v) ?: $d);
            $authTable = $ident((string) ($sec['users_table'] ?? 'users'), 'users');
            $columns = [
                'id'       => 'id',
                'username' => $ident((string) ($sec['username_field'] ?? 'username'), 'username'),
                'email'    => $ident((string) ($sec['email_field'] ?? 'email'), 'email'),
                'password' => $ident((string) ($sec['password_field'] ?? 'password'), 'password'),
            ];
            // Optional identity fields — only included when actually mapped, so
            // the runtime never SELECTs a column the table does not have.
            foreach (['fullname' => 'fullname_field', 'role' => 'role_field', 'status' => 'status_field'] as $logical => $key) {
                $v = trim((string) ($sec[$key] ?? ''));
                if ($v !== '') {
                    $columns[$logical] = $ident($v, $logical);
                }
            }
            // A freshly-created standard table always carries totp_secret; an
            // existing table only gets it when explicitly mapped (2FA setup).
            $totp = trim((string) ($sec['totp_secret_field'] ?? ($sec['two_factor']['totp_secret_field'] ?? '')));
            if ($totp !== '') {
                $columns['totp_secret'] = $ident($totp, 'totp_secret');
            } elseif (!empty($sec['create_new_table'])) {
                $columns['totp_secret'] = 'totp_secret';
            }
            $authFile = $configDir . '/auth.php';
            file_put_contents(
                $authFile,
                "<?php\n\ndeclare(strict_types=1);\n\n// Authentication table + column map — from the Security module.\n// Read by core/Auth/UserSchema; do not edit by hand.\nreturn "
                . var_export(['table' => $authTable, 'columns' => $columns], true) . ";\n"
            );
            $this->log("Wrote config/auth.php (auth table: {$authTable})");
            $written[] = $authFile;
        }

        // ── index.php entry point → landing page (Misc → Project) or dashboard ──
        $landing = (array) ($this->ctx['miscProject']['landingPage'] ?? []);
        $landingRoute = '';
        if (($landing['type'] ?? '') === 'page' && !empty($landing['value']) && $landing['value'] !== 'dashboard') {
            $landingRoute = '/generated/' . preg_replace('#[^a-zA-Z0-9_/-]#', '', (string) $landing['value']) . '/list.php';
        } elseif (($landing['type'] ?? '') === 'custom' && !empty($landing['value'])) {
            $landingRoute = addslashes((string) $landing['value']);
        }
        // Standalone app with no explicit landing → serve its Home page at "/".
        if ($landingRoute === '' && \PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug)) {
            $saPages = array_values(array_filter(
                array_keys((array) ($this->ctx['designer']['pages']['__standalone__'] ?? [])),
                static fn ($pt) => is_string($pt) && str_starts_with($pt, 'custom_')
            ));
            $home = null;
            foreach (['custom_home', 'custom_index'] as $pref) {
                if (in_array($pref, $saPages, true)) { $home = $pref; break; }
            }
            if ($home === null && $saPages !== []) $home = $saPages[0];
            if ($home !== null) {
                $landingRoute = '/generated/__standalone__/' . preg_replace('#[^a-zA-Z0-9_-]#', '', $home) . '.php';
            }
        }

        $indexFile = $outputDir . '/index.php';
        $escapedSlug = addslashes($slug);
        $indexContent = <<<PHP
<?php
declare(strict_types=1);

define('BASE_PATH', __DIR__);

\$appConfig = require __DIR__ . '/config/app.php';

error_reporting(\$appConfig['debug'] ? E_ALL : 0);
ini_set('display_errors', \$appConfig['debug'] ? '1' : '0');

// Bootstrap runtime (also auto-detects APP_BASE / BASE_URL, the app's URL prefix)
require_once __DIR__ . '/_runtime.php';

// Handle root request → redirect to dashboard
\$uri = parse_url(\$_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Strip the auto-detected base prefix so subdirectory hosting resolves files correctly
if (APP_BASE !== '' && str_starts_with(\$uri, APP_BASE)) {
    \$uri = substr(\$uri, strlen(APP_BASE));
    if (\$uri === '' || \$uri === false) \$uri = '/';
}

// Project public routes (optional). A build's Custom Files may ship a
// `public-routes.php` at the app root to serve custom public pages at clean
// URLs (e.g. a forum front-end) — including the root "/". It runs with \$uri and
// APP_BASE in scope and may render + exit, or return to fall through to the
// default routing (dashboard / aliases / SEO / files) below.
\$__publicRoutes = __DIR__ . '/public-routes.php';
if (is_file(\$__publicRoutes)) {
    require \$__publicRoutes;
}

if (\$uri === '/') {
    \$landing = '$landingRoute';
    if (\$landing !== '') {
        header('Location: ' . (str_starts_with(\$landing, 'http') ? \$landing : APP_BASE . \$landing));
        exit;
    }
    \$dashboardFile = __DIR__ . '/generated/dashboard/index.php';
    if (is_file(\$dashboardFile)) {
        require \$dashboardFile;
        exit;
    }
}

// Pretty-route aliases (auth guards redirect to these)
\$aliases = [
    '/login'           => '/generated/auth/login.php',
    '/logout'          => '/generated/auth/logout.php',
    '/register'        => '/generated/auth/register.php',
    '/forgot-password' => '/generated/auth/forgot-password.php',
    '/profile'         => '/generated/auth/profile.php',
    '/sitemap.xml'     => '/sitemap.php',
];
if (isset(\$aliases[\$uri]) && is_file(__DIR__ . \$aliases[\$uri])) {
    require __DIR__ . \$aliases[\$uri];
    exit;
}

// SEO slug URLs (feature A1). No behaviour change unless the build registered
// routes: with an empty/absent config this block does nothing and normal file
// routing continues. On a match, dispatch() 301s to canonical / requires the
// view page / 404s and exits; otherwise it returns and we fall through.
\$seoRoutesFile = __DIR__ . '/config/seo-routes.php';
if (is_file(\$seoRoutesFile)) {
    \$seoRoutes = require \$seoRoutesFile;
    if (is_array(\$seoRoutes) && \$seoRoutes !== []) {
        \PpPhp\Core\Seo\SlugRouter::dispatch(\$uri, \$seoRoutes, __DIR__);
    }
}

// Custom AJAX action endpoints (feature A6). Inert unless the path is exactly
// /action/<name>; dispatch() CSRF-guards, auth-gates, runs the handler, exits JSON.
\$__action = \PpPhp\Core\Actions\ActionRouter::match(\$uri);
if (\$__action !== null) {
    \PpPhp\Core\Actions\ActionRouter::dispatch(\$__action, __DIR__);
}

// Route to generated PHP pages
\$possibleFile = __DIR__ . \$uri;
if (is_file(\$possibleFile) && str_ends_with(\$possibleFile, '.php')) {
    require \$possibleFile;
    exit;
}

// Try with index.php fallback
\$dirFile = rtrim(\$possibleFile, '/') . '/index.php';
if (is_file(\$dirFile)) {
    require \$dirFile;
    exit;
}

http_response_code(404);
if (is_file(__DIR__ . '/404.php')) {
    require __DIR__ . '/404.php';
} else {
    echo '<!DOCTYPE html><html><head><title>404 - Page Not Found</title></head><body><h1>404</h1><p>The requested page was not found.</p></body></html>';
}
PHP;
        file_put_contents($indexFile, $indexContent);
        $this->log('Wrote index.php');
        $written[] = $indexFile;

        // ── config/seo-routes.php (feature A1; empty unless a page opts in) ──
        $seoRoutes = $this->seoRoutesConfig();
        $seoFile = $configDir . '/seo-routes.php';
        file_put_contents($seoFile, "<?php\n\ndeclare(strict_types=1);\n\nreturn " . var_export($seoRoutes, true) . ";\n");
        $this->log($seoRoutes !== [] ? ('Wrote config/seo-routes.php (' . count($seoRoutes) . ' route(s))') : 'Wrote config/seo-routes.php (none)');
        $written[] = $seoFile;

        // ── config/seo-meta.php (feature A2; per-record view meta, empty unless a page opts in) ──
        $seoMeta = $this->seoMetaConfigAll();
        $seoMetaFile = $configDir . '/seo-meta.php';
        file_put_contents($seoMetaFile, "<?php\n\ndeclare(strict_types=1);\n\nreturn " . var_export($seoMeta, true) . ";\n");
        $this->log($seoMeta !== [] ? ('Wrote config/seo-meta.php (' . count($seoMeta) . ' page(s))') : 'Wrote config/seo-meta.php (none)');
        $written[] = $seoMetaFile;

        // ── sitemap.php (feature A4) — only when a page opts in, so no-SEO apps
        //    gain no new file and /sitemap.xml keeps 404ing exactly as before. ──
        if ($seoMeta !== []) {
            $sitemapFile = $outputDir . '/sitemap.php';
            file_put_contents($sitemapFile, <<<'SITEMAP'
<?php
// Dynamic XML sitemap (platform feature A4). Enumerates SEO-enabled pages'
// records as canonical slug URLs; fans out to a <sitemapindex> past 50k URLs.
require_once __DIR__ . '/_runtime.php';

$all = @include __DIR__ . '/config/seo-meta.php';
$all = is_array($all) ? $all : [];

$safe = static fn ($s): string => preg_match('/^[A-Za-z0-9_]+$/', (string) $s) ? (string) $s : '';
$tables = [];
foreach ($all as $table => $cfg) {
    $t = $safe($table);
    if ($t === '' || empty($cfg['prefix']) || (($cfg['robots'] ?? '') === 'noindex')) {
        continue;
    }
    $idc = $safe($cfg['id_column'] ?? 'id');
    $tables[] = [
        'table'          => $t,
        'prefix'         => (string) $cfg['prefix'],
        'id_column'      => $idc !== '' ? $idc : 'id',
        'slug_column'    => $safe($cfg['slug_column'] ?? ''),
        'title_column'   => $safe($cfg['title_column'] ?? ''),
        'lastmod_column' => $safe($cfg['lastmod_column'] ?? ''),
        'changefreq'     => (string) ($cfg['changefreq'] ?? ''),
        'priority'       => (string) ($cfg['priority'] ?? ''),
    ];
}

$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$base = isset($_SERVER['HTTP_HOST']) ? $scheme . '://' . $_SERVER['HTTP_HOST'] . APP_BASE : APP_BASE;

// Identifiers are validated to [A-Za-z0-9_]; left unquoted so the SQL is valid
// on MySQL/MariaDB/TiDB, PostgreSQL/CockroachDB, SQLite, SQL Server and Oracle.
$counter = static function (string $t): int {
    return (int) dbValue("SELECT COUNT(*) FROM $t");
};
$fetcher = static function (string $t, int $limit, int $offset) use ($tables): array {
    $cfg = null;
    foreach ($tables as $c) { if ($c['table'] === $t) { $cfg = $c; break; } }
    if ($cfg === null) { return []; }
    $cols = [$cfg['id_column']];
    foreach (['slug_column', 'title_column', 'lastmod_column'] as $k) {
        if ($cfg[$k] !== '') { $cols[] = $cfg[$k]; }
    }
    $limit = (int) $limit; $offset = (int) $offset;
    $sql = 'SELECT ' . implode(', ', array_values(array_unique($cols)))
        . " FROM {$t} ORDER BY {$cfg['id_column']} LIMIT {$limit} OFFSET {$offset}";
    return dbAll($sql) ?: [];
};

header('Content-Type: application/xml; charset=UTF-8');
echo \PpPhp\Core\Seo\SitemapBuilder::respond($tables, $base, $_GET, $counter, $fetcher);
SITEMAP);
            $this->log('Wrote sitemap.php');
            $written[] = $sitemapFile;
        }

        // ── .htaccess (honours the Output module setting) ──
        if ($outputConfig['advanced']['generateHtaccess'] ?? true) {
        $htFile = $outputDir . '/.htaccess';
        $htContent = <<<HTACCESS
<IfModule mod_rewrite.c>
    RewriteEngine On
    
    # Redirect trailing slashes (relative to RewriteBase)
    RewriteRule ^(.+)/$ $1 [L,R=301]
    
    # Handle front controller
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [L]
</IfModule>

<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
</IfModule>

<IfModule mod_headers.c>
    Header set X-Content-Type-Options "nosniff"
    # SAMEORIGIN (not DENY) so same-origin iframes work — e.g. the Add-in-popup
    # modal — while cross-origin framing (clickjacking) is still blocked.
    Header set X-Frame-Options "SAMEORIGIN"
    Header set X-XSS-Protection "1; mode=block"
</IfModule>
HTACCESS;
        file_put_contents($htFile, $htContent);
        $this->log('Wrote .htaccess');
        $written[] = $htFile;
        } else {
            $this->log('Skipped .htaccess (disabled in Output settings)');
        }

        // ── web.config for IIS (honours the Output module setting) ──
        if ($outputConfig['advanced']['generateWebConfig'] ?? false) {
            $wcFile = $outputDir . '/web.config';
            file_put_contents($wcFile, <<<'WEBCONFIG'
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <rewrite>
      <rules>
        <rule name="FrontController" stopProcessing="true">
          <match url=".*" />
          <conditions logicalGrouping="MatchAll">
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
            <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
          </conditions>
          <action type="Rewrite" url="index.php" />
        </rule>
      </rules>
    </rewrite>
    <httpProtocol>
      <customHeaders>
        <add name="X-Content-Type-Options" value="nosniff" />
        <add name="X-Frame-Options" value="SAMEORIGIN" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>
WEBCONFIG);
            $this->log('Wrote web.config');
            $written[] = $wcFile;
        }

        // ── robots.txt (honours the Output module setting) ──
        if ($outputConfig['advanced']['generateRobotsTxt'] ?? false) {
            $rbFile = $outputDir . '/robots.txt';
            $rbBody = "User-agent: *\nDisallow: /api/\nDisallow: /generated/auth/\nDisallow: /app/\nDisallow: /config/\nDisallow: /forum/\n";
            // Point crawlers at the dynamic sitemap (feature A4) when SEO pages exist
            // and we know the absolute base URL (robots Sitemap must be absolute).
            if ($seoMeta !== [] && preg_match('#^https?://#i', (string) $baseUrl)) {
                $rbBody .= 'Sitemap: ' . rtrim((string) $baseUrl, '/') . "/sitemap.xml\n";
            }
            file_put_contents($rbFile, $rbBody);
            $this->log('Wrote robots.txt');
            $written[] = $rbFile;
        }

        return $written;
    }

    /**
     * Minify CSS (and lightly compact JS) assets in the output directory.
     * Already-minified vendor files (*.min.css / *.min.js) are left untouched.
     */
    public function minifyAssets(string $outputDir, bool $css, bool $js): void
    {
        if (!$css && !$js) return;
        $this->log('Minifying assets…');
        $savedTotal = 0;

        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($outputDir . '/assets', \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $path = $file->getPathname();
            $name = $file->getFilename();
            if (str_contains($name, '.min.')) continue;

            $isCss = $css && str_ends_with($name, '.css');
            $isJs  = $js && str_ends_with($name, '.js');
            if (!$isCss && !$isJs) continue;

            $src = (string) file_get_contents($path);
            $out = $isCss ? $this->minifyCss($src) : $this->compactJs($src);
            if (strlen($out) < strlen($src)) {
                file_put_contents($path, $out);
                $savedTotal += strlen($src) - strlen($out);
            }
        }

        $this->log('Minification saved ' . round($savedTotal / 1024, 1) . ' KB');
    }

    private function minifyCss(string $css): string
    {
        $css = preg_replace('!/\*.*?\*/!s', '', $css);          // comments
        $css = preg_replace('/\s+/', ' ', $css);                  // collapse whitespace
        $css = preg_replace('/\s*([{};:,>])\s*/', '$1', $css);    // trim around punctuation
        $css = str_replace(';}', '}', $css);
        return trim($css);
    }

    /** Conservative JS compaction: drop full-line comments and blank lines only. */
    private function compactJs(string $js): string
    {
        $lines = [];
        $inBlockComment = false;
        foreach (explode("\n", $js) as $line) {
            $trim = trim($line);
            if ($inBlockComment) {
                if (str_contains($trim, '*/')) $inBlockComment = false;
                continue;
            }
            if ($trim === '' || str_starts_with($trim, '//')) continue;
            if (str_starts_with($trim, '/*')) {
                if (!str_contains($trim, '*/')) $inBlockComment = true;
                continue;
            }
            $lines[] = rtrim($line);
        }
        return implode("\n", $lines);
    }

    /** Append ?v=<hash> to local asset URLs in every generated PHP/HTML file. */
    /**
     * Append a per-build cache-busting query (?v=…) to asset URLs across every
     * generated .php/.html page, so a redeploy is never served a stale stylesheet
     * or script from the browser cache.
     *
     * $scope 'platform' (the default, always run) versions only the mutable,
     * build-emitted assets — assets/css/*.css and assets/js/*.js — so vendored
     * libraries (bootstrap, fontawesome, fonts…) keep their long-lived cache.
     * $scope 'all' (Output → "Version asset URLs") versions every /assets/ CSS+JS.
     *
     * The URL body may be a runtime PHP echo (`<?= APP_BASE ?>/assets/…`) whose
     * own `?`/`>` characters defeated the previous `[^"?]*` pattern — matching up
     * to the .css/.js extension explicitly fixes that. URLs that already carry a
     * query are skipped (the filename class stops before any `?`).
     */
    public function applyAssetVersioning(string $outputDir, string $version, string $scope = 'all'): void
    {
        // Cache-bust token derived from the ACTUAL build-emitted assets (theme/app
        // CSS+JS) plus the platform version — NOT the wall clock. So the token changes
        // exactly when the assets change (correct busting) and is identical across
        // rebuilds of an unchanged app (deterministic output → tiny incremental
        // rebuilds, and browsers keep valid caches on a no-op rebuild).
        $v = substr(hash('sha256', $version . '|' . $this->assetsSignature($outputDir)), 0, 10);
        $seg = $scope === 'platform' ? '/assets/(?:css|js)/' : '/assets/(?:[A-Za-z0-9_./-]*/)?';
        $this->log("Applying asset versioning (?v=$v, scope=$scope)…");

        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            $name = $file->getFilename();
            if (!str_ends_with($name, '.php') && !str_ends_with($name, '.html')) continue;

            $src = (string) file_get_contents($file->getPathname());
            if (strpos($src, '/assets/') === false) continue;
            $out = preg_replace(
                '#(src|href)="([^"]*?' . $seg . '[A-Za-z0-9_.-]+\.(?:css|js))"#',
                '$1="$2?v=' . $v . '"',
                $src
            );
            if ($out !== null && $out !== $src) {
                file_put_contents($file->getPathname(), $out);
            }
        }
    }

    /**
     * A stable fingerprint of the build-emitted, mutable assets (theme/app CSS+JS).
     * Feeds the asset cache-bust token so it changes exactly when those assets do —
     * deterministic (no wall clock), so an unchanged app rebuilds to identical bytes.
     * Vendored libraries are intentionally excluded: they change only with the
     * platform version, which is already mixed into the token.
     */
    private function assetsSignature(string $outputDir): string
    {
        $parts = [];
        foreach (['assets/css', 'assets/js'] as $sub) {
            $dir = $outputDir . '/' . $sub;
            if (!is_dir($dir)) continue;
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
            );
            foreach ($it as $f) {
                if (!$f->isFile()) continue;
                $rel = $sub . '/' . str_replace('\\', '/', $it->getSubPathname());
                $h = @hash_file('sha256', $f->getPathname());
                if ($h !== false) $parts[$rel] = $h;
            }
        }
        ksort($parts);   // path order must never change the fingerprint
        $flat = '';
        foreach ($parts as $rel => $h) $flat .= $rel . ':' . $h . "\n";
        return hash('sha256', $flat);
    }

    /**
     * Native-mobile stylesheet layer.
     *
     * Writes assets/css/mobile.css (from the platform source public/assets/css/
     * app-mobile.css) and injects it LAST into every generated page's <head>
     * — right before </head>, so it wins the cascade over theme/shell/app css
     * without specificity fights. EVERY rule in that file lives inside a
     * max-width media query, so desktop output is byte-identical; only phones/
     * tablets pick up the native-app polish (drawer already exists; this adds
     * scrollable tables, 16px inputs, sticky action bars, stacked hero, etc.).
     *
     * `</head>` is the one anchor present in 100% of generated pages (CRUD,
     * dashboard, view, auth/profile/change-password compilers), so a single
     * str_replace reaches them all. Restricted to .php (where <?= APP_BASE ?>
     * resolves) and skips vendored libraries. Runs before applyAssetVersioning
     * so the link gets the content-hash ?v= cache-bust for free.
     */
    public function injectMobileStylesheet(string $outputDir): void
    {
        $cssDir = $outputDir . '/assets/css';
        if (!is_dir($cssDir)) return;

        // Honour the Mobile settings page (config/mobile.json). Master off → the
        // generated app keeps its plain responsive layout (no native layer at all).
        $mob = $this->mobileSettings();
        if ($mob['enabled'] === false) { $this->log('Mobile layer disabled by settings — skipped.'); return; }

        // 1) Emit the stylesheet + companion JS (assets/ css & js are generated,
        //    not copied — see copyAssets). The JS adds the one native affordance
        //    CSS can't: a bottom tab bar built from the app's own menu (phones only).
        $cssSrc = dirname(__DIR__, 2) . '/public/assets/css/app-mobile.css';
        $css = is_file($cssSrc) ? (string) file_get_contents($cssSrc) : '';
        if ($css === '') { $this->log('Skipped mobile layer (source app-mobile.css missing)'); return; }
        $this->writeFile($cssDir . '/mobile.css', $css);

        $jsSrc = dirname(__DIR__, 2) . '/public/assets/js/app-mobile.js';
        $hasJs = is_file($jsSrc);
        if ($hasJs) {
            $jsDir = $outputDir . '/assets/js';
            if (!is_dir($jsDir)) @mkdir($jsDir, 0775, true);
            $this->writeFile($jsDir . '/mobile.js', (string) file_get_contents($jsSrc));
        }

        // 2) Inject the CSS link (last, before </head>) + viewport-fit=cover, and the
        //    JS (before </body>), into every generated .php page. </head>/</body> are
        //    the anchors present in all of them; APP_BASE only resolves in PHP.
        $cssLink = '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/mobile.css"></head>';
        // Runtime feature flags (from the Mobile settings page) — app-mobile.js reads
        // window.PP_MOBILE before it runs, so this inline config must precede the script.
        $ppmCfg  = $mob['features'];
        $ppmCfg['pwa'] = ($mob['pwa']['enabled'] !== false);
        $ppmCfg['darkMode'] = $mob['darkMode']; // 'off'|'auto'|'light'|'dark'
        $ppmCfg['flavours'] = $mob['flavours']; // per-component flavour matrix
        if (!empty($mob['push']['vapidPublic'])) $ppmCfg['vapidKey'] = $mob['push']['vapidPublic']; // enables Web Push client
        $ppmCfg['tabbarCfg'] = $mob['tabbar'];   // mode/count/showLabels/effect/items
        $ppmCfg['edgeNavCfg'] = $mob['edgeNav']; // size/bg/iconColor/opacity
        $jsTag   = '<script>window.PP_MOBILE=' . json_encode($ppmCfg, JSON_UNESCAPED_SLASHES) . ';</script>'
            . '<script src="<?= APP_BASE ?>/assets/js/mobile.js" defer></script></body>';
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        $count = 0;
        foreach ($it as $file) {
            if (!$file->isFile()) continue;
            if (!str_ends_with($file->getFilename(), '.php')) continue;
            if (strpos(str_replace('\\', '/', $file->getPathname()), '/vendor/') !== false) continue;

            $src = (string) file_get_contents($file->getPathname());
            if (strpos($src, '</head>') === false) continue;
            $out = $src;

            if (strpos($out, '/assets/css/mobile.css') === false) {       // idempotent
                $out = str_replace('</head>', $cssLink, $out);
                if (stripos($out, 'viewport-fit') === false) {
                    $out = preg_replace(
                        '/(<meta\s+name=["\']viewport["\'][^>]*content=["\'][^"\']*)(["\'])/i',
                        '$1,viewport-fit=cover$2',
                        $out,
                        1
                    ) ?? $out;
                }
            }
            if ($hasJs && strpos($out, '/assets/js/mobile.js') === false && strpos($out, '</body>') !== false) {
                $out = str_replace('</body>', $jsTag, $out);
            }
            if ($out !== $src) { file_put_contents($file->getPathname(), $out); $count++; }
        }
        $this->log("Wrote mobile.css" . ($hasJs ? ' + mobile.js' : '') . " and injected into {$count} page(s)");
    }

    /** First value of a CSS custom property in a stylesheet string. */
    private function cssVar(string $css, string $name, string $fallback): string
    {
        return preg_match('/--' . preg_quote($name, '/') . '\s*:\s*([^;]+);/', $css, $m)
            ? trim($m[1]) : $fallback;
    }
    /** Mobile settings (config/mobile.json via ctx) merged into the full known schema. */
    private function mobileSettings(): array
    {
        $in = (array) ($this->ctx['mobile'] ?? []);
        $feat = static fn ($v, $def) => is_bool($v) ? $v : ($v === null ? $def : (bool) $v);
        $fIn = (array) ($in['features'] ?? []);
        $pIn = (array) ($in['pwa'] ?? []);
        $defFeat = [
            // shell + navigation
            'tabbar' => true, 'edgeNav' => true, 'edgeSwipe' => true, 'swipeActions' => true,
            'skeleton' => true, 'haptics' => true, 'pageTransition' => true,
            // Phase A — component mobilisation
            'inputEnhance' => true, 'nativePickers' => true, 'cardify' => true, 'liveSearch' => true,
            'stickyHeaders' => true, 'emptyStates' => true, 'toasts' => true, 'sheets' => true,
            // Phase B — signature interactions
            'pullRefresh' => true, 'infiniteScroll' => false, 'modalDetail' => false,
            'viewTransitions' => true, 'kpiCarousel' => true,
            // Phase C — platform depth
            'offline' => false, 'push' => false, 'shareTarget' => false, 'installPrompt' => true, 'appBadge' => true,
            'formDrafts' => true,
        ];
        $features = [];
        foreach ($defFeat as $k => $def) $features[$k] = $feat($fIn[$k] ?? null, $def);
        // Appearance: dark-mode strategy (not a boolean toggle).
        $darkMode = in_array(($in['darkMode'] ?? ''), ['off', 'auto', 'light', 'dark'], true) ? $in['darkMode'] : 'off';

        // Per-component flavour matrix.
        $flvIn = (array) ($in['flavours'] ?? []);
        $flavours = [
            'list'    => in_array(($flvIn['list'] ?? ''), ['auto', 'cards', 'table'], true) ? $flvIn['list'] : 'auto',
            'dates'   => in_array(($flvIn['dates'] ?? ''), ['custom', 'native'], true) ? $flvIn['dates'] : 'custom',
            'selects' => in_array(($flvIn['selects'] ?? ''), ['native', 'sheet'], true) ? $flvIn['selects'] : 'native',
            'numbers' => in_array(($flvIn['numbers'] ?? ''), ['plain', 'stepper'], true) ? $flvIn['numbers'] : 'plain',
        ];

        $tIn = (array) ($in['tabbar'] ?? []);
        $effects = ['none', 'pop', 'bounce', 'pulse', 'glow', 'wiggle'];
        $items = [];
        foreach ((array) ($tIn['items'] ?? []) as $it) {
            if (!is_array($it)) continue;
            $icon = preg_replace('/[^a-z0-9-]/', '', strtolower((string) ($it['icon'] ?? 'bi-dot'))) ?: 'bi-dot';
            $items[] = [
                'icon'    => $icon,
                'label'   => mb_substr(trim((string) ($it['label'] ?? '')), 0, 20),
                'link'    => mb_substr(trim((string) ($it['link'] ?? '')), 0, 300),
                'enabled' => $feat($it['enabled'] ?? null, true),
            ];
            if (count($items) >= 5) break;
        }
        $tabbar = [
            'mode'       => in_array(($tIn['mode'] ?? ''), ['auto', 'custom'], true) ? $tIn['mode'] : 'auto',
            'count'      => max(2, min(5, (int) ($tIn['count'] ?? 4))),
            'showLabels' => $feat($tIn['showLabels'] ?? null, true),
            'effect'     => in_array(($tIn['effect'] ?? ''), $effects, true) ? $tIn['effect'] : 'none',
            'items'      => $items,
        ];

        $eIn = (array) ($in['edgeNav'] ?? []);
        $hexv = static function ($c) { $c = trim((string) $c); return ($c !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $c)) ? $c : ''; };
        $eop = is_numeric($eIn['opacity'] ?? null) ? (float) $eIn['opacity'] : 0.5;
        $edgeNav = [
            'size'      => max(40, min(80, (int) ($eIn['size'] ?? 52))),
            'bg'        => $hexv($eIn['bg'] ?? ''),
            'iconColor' => $hexv($eIn['iconColor'] ?? ''),
            'opacity'   => max(0.2, min(1.0, $eop)),
        ];

        $pushIn = (array) ($in['push'] ?? []);
        $push = [
            'vapidPublic'  => preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($pushIn['vapidPublic'] ?? '')),
            'vapidPrivate' => preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($pushIn['vapidPrivate'] ?? '')),
            'subject'      => mb_substr(trim((string) ($pushIn['subject'] ?? '')), 0, 120),
        ];

        return [
            'enabled'  => $feat($in['enabled'] ?? null, true),
            'features' => $features,
            'darkMode' => $darkMode,
            'flavours' => $flavours,
            'push'     => $push,
            'tabbar'   => $tabbar,
            'edgeNav'  => $edgeNav,
            'pwa' => [
                'enabled'    => $feat($pIn['enabled'] ?? null, true),
                'appName'    => trim((string) ($pIn['appName'] ?? '')),
                'themeColor' => trim((string) ($pIn['themeColor'] ?? '')),
                'iconFile'   => preg_replace('/[^a-zA-Z0-9._-]/', '', (string) ($pIn['iconFile'] ?? '')),
            ],
        ];
    }

    /** '#rrggbb' | '#rgb' → [r,g,b]. */
    private function hexRgb(string $hex): array
    {
        $hex = ltrim(trim($hex), '#');
        if (strlen($hex) === 3) $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        if (strlen($hex) < 6 || !ctype_xdigit($hex)) return [79, 70, 229];
        return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
    }

    /**
     * Progressive Web App layer — makes a generated app installable ("Add to Home
     * Screen") and launchable fullscreen with its own icon. Modular & self-contained:
     * generates its own assets and injects only its own <head> tags, so it can be
     * changed without touching the mobile CSS/JS injection.
     *
     * Emits (all app-specific — NO PPPHP branding): manifest.webmanifest, sw.js
     * (network-first pages so DB data stays fresh; stale-while-revalidate assets),
     * an SVG app icon (crisp, colour + initial) and PNG icons via GD (apple-touch
     * for iOS; the letter is drawn when a system TTF is found, else a clean solid
     * tile). Colours are read from the compiled theme.css so it matches the theme.
     */
    public function generatePwaAssets(string $outputDir): void
    {
        // Honour the Mobile settings page: skip entirely when the mobile layer or
        // the PWA option is turned off.
        $mob = $this->mobileSettings();
        if ($mob['enabled'] === false || $mob['pwa']['enabled'] === false) {
            $this->log('PWA disabled by settings — skipped.');
            return;
        }

        $cssDir = $outputDir . '/assets/css';
        $theme  = @file_get_contents($cssDir . '/theme.css') ?: '';
        // Theme color: explicit override from the Mobile page wins, else theme primary.
        $primary   = ($mob['pwa']['themeColor'] !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $mob['pwa']['themeColor']))
            ? $mob['pwa']['themeColor'] : $this->cssVar($theme, 'pp-primary', '#4f46e5');
        $onPrimary = $this->cssVar($theme, 'pp-primary-text', '#ffffff');
        $bg        = $this->cssVar($theme, 'pp-bg', '#ffffff');

        // App name: explicit override from the Mobile page wins, else project name.
        $appName = $mob['pwa']['appName'] !== '' ? $mob['pwa']['appName']
            : (trim((string) ($this->ctx['miscProject']['appName']
                ?? $this->ctx['project']['name'] ?? $this->ctx['title'] ?? '')) ?: 'App');
        $short   = mb_substr($appName, 0, 12);
        $initial = strtoupper(mb_substr($appName, 0, 1)) ?: 'A';
        $h = static fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES);

        // ── App icons: a user-uploaded icon (Mobile page) overrides the auto one. ──
        $srcIcon = ($mob['pwa']['iconFile'] !== '')
            ? \PpPhp\Core\ProjectLocator::dir((string) ($this->ctx['slug'] ?? '')) . '/config/' . basename((string) $mob['pwa']['iconFile'])
            : '';
        $hasUpload = $srcIcon !== '' && is_file($srcIcon);
        $uploadExt = $hasUpload ? strtolower(pathinfo($srcIcon, PATHINFO_EXTENSION)) : '';
        $pngSet = static fn () => [
            ['src' => 'assets/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any maskable'],
            ['src' => 'assets/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any maskable'],
        ];
        $manifestIcons = [];

        if ($hasUpload && $uploadExt === 'svg') {
            // Uploaded vector → manifest SVG; theme-generated PNGs remain for iOS.
            $this->writeFile($cssDir . '/../icon.svg', (string) file_get_contents($srcIcon));
            $ttf = $this->findSystemTtf();
            foreach ([192 => 'icon-192.png', 512 => 'icon-512.png', 180 => 'apple-touch-icon.png'] as $size => $file) {
                $png = $this->pwaPng((int) $size, $primary, $onPrimary, $ttf ? $initial : '', $ttf);
                if ($png !== '') $this->writeFile($outputDir . '/assets/' . $file, $png);
            }
            $manifestIcons = array_merge([['src' => 'assets/icon.svg', 'sizes' => 'any', 'type' => 'image/svg+xml', 'purpose' => 'any maskable']], $pngSet());
        } elseif ($hasUpload) {
            // Uploaded raster → resized square PNG icon set (cover-fit).
            foreach ([192 => 'icon-192.png', 512 => 'icon-512.png', 180 => 'apple-touch-icon.png'] as $size => $file) {
                $png = $this->pwaResize($srcIcon, (int) $size);
                if ($png !== '') $this->writeFile($outputDir . '/assets/' . $file, $png);
            }
            $manifestIcons = $pngSet();
        } else {
            // Auto: theme SVG (full-bleed, maskable-safe) + GD PNGs (colour + initial).
            $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
                . '<rect width="512" height="512" fill="' . $h($primary) . '"/>'
                . '<text x="256" y="256" text-anchor="middle" dominant-baseline="central" '
                . 'font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif" '
                . 'font-size="272" font-weight="700" fill="' . $h($onPrimary) . '">' . $h($initial) . '</text></svg>';
            $this->writeFile($cssDir . '/../icon.svg', $svg);
            $ttf = $this->findSystemTtf();
            foreach ([192 => 'icon-192.png', 512 => 'icon-512.png', 180 => 'apple-touch-icon.png'] as $size => $file) {
                $png = $this->pwaPng((int) $size, $primary, $onPrimary, $ttf ? $initial : '', $ttf);
                if ($png !== '') $this->writeFile($outputDir . '/assets/' . $file, $png);
            }
            $manifestIcons = array_merge([['src' => 'assets/icon.svg', 'sizes' => 'any', 'type' => 'image/svg+xml', 'purpose' => 'any maskable']], $pngSet());
        }

        // ── manifest.webmanifest (icon srcs are relative to the app root) ──
        $manifest = [
            'name' => $appName, 'short_name' => $short,
            'start_url' => '.', 'scope' => './', 'display' => 'standalone',
            'orientation' => 'any',
            'theme_color' => $primary, 'background_color' => $bg,
            'icons' => $manifestIcons,
        ];
        // Share target: the app appears in the OS share sheet → share.php captures it.
        if (!empty($mob['features']['shareTarget'])) {
            $manifest['share_target'] = [
                'action' => 'share.php', 'method' => 'GET',
                'params' => ['title' => 'title', 'text' => 'text', 'url' => 'url'],
            ];
        }
        $this->writeFile($outputDir . '/manifest.webmanifest', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));

        // ── Service worker (root scope) ──
        $this->writeFile($outputDir . '/sw.js', $this->serviceWorkerJs());

        // ── Share-target receiver: fold the shared title/text/url into one param and
        //    hand it back to the app (app-mobile.js surfaces it; wire to an add form). ──
        if (!empty($mob['features']['shareTarget'])) {
            $this->writeFile($outputDir . '/share.php', "<?php\n"
                . "\$p = trim(implode(' ', array_filter([\$_GET['title'] ?? '', \$_GET['text'] ?? '', \$_GET['url'] ?? ''])));\n"
                . "header('Location: ' . (defined('APP_BASE') ? APP_BASE : '.') . '/?pp_shared=' . rawurlencode(\$p));\n");
        }
        // ── Web Push kit (only when push is on AND a VAPID key pair is configured):
        //    subscription store, the self-contained sender, a pp_push() helper, and a
        //    CLI/token test sender. The private key lives server-side in config/push.php. ──
        if (!empty($mob['features']['push']) && !empty($mob['push']['vapidPublic']) && !empty($mob['push']['vapidPrivate'])) {
            $this->writeFile($outputDir . '/api/push-subscribe.php', $this->pushSubscribePhp());
            // Sender class (verbatim copy of the platform implementation).
            $wp = dirname(__DIR__, 2) . '/core/Push/WebPush.php';
            if (is_file($wp)) $this->writeFile($outputDir . '/push/WebPush.php', (string) file_get_contents($wp));
            // Server-only config (holds the VAPID PRIVATE key + a token for the web test endpoint).
            $token = bin2hex(random_bytes(16));
            $cfg = "<?php\n// Web Push config — server-side only. Do NOT expose the private key to clients.\nreturn "
                . var_export([
                    'publicKey'  => (string) $mob['push']['vapidPublic'],
                    'privateKey' => (string) $mob['push']['vapidPrivate'],
                    'subject'    => (string) ($mob['push']['subject'] ?: 'mailto:admin@example.com'),
                    'token'      => $token,
                ], true) . ";\n";
            $this->writeFile($outputDir . '/config/push.php', $cfg);
            $this->writeFile($outputDir . '/push/push.php', $this->pushHelperPhp());
            $this->writeFile($outputDir . '/push/send-test.php', $this->pushSendTestPhp());
        }

        // ── Inject PWA <head> tags into every generated .php page ──
        $tags = '<link rel="manifest" href="<?= APP_BASE ?>/manifest.webmanifest">'
            . '<meta name="theme-color" content="' . $h($primary) . '">'
            . '<meta name="mobile-web-app-capable" content="yes">'
            . '<meta name="apple-mobile-web-app-capable" content="yes">'
            . '<meta name="apple-mobile-web-app-status-bar-style" content="default">'
            . '<meta name="apple-mobile-web-app-title" content="' . $h($short) . '">'
            . '<link rel="apple-touch-icon" href="<?= APP_BASE ?>/assets/apple-touch-icon.png">'
            . '</head>';
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS));
        $n = 0;
        foreach ($it as $file) {
            if (!$file->isFile() || !str_ends_with($file->getFilename(), '.php')) continue;
            if (strpos(str_replace('\\', '/', $file->getPathname()), '/vendor/') !== false) continue;
            $src = (string) file_get_contents($file->getPathname());
            if (strpos($src, '</head>') === false || strpos($src, 'rel="manifest"') !== false) continue;
            $out = str_replace('</head>', $tags, $src);
            if ($out !== $src) { file_put_contents($file->getPathname(), $out); $n++; }
        }
        $this->log("PWA: manifest + sw.js + icons; injected head tags into {$n} page(s)");
    }

    /** Locate a bold system TTF for GD icon text; null if none (icon still renders solid). */
    private function findSystemTtf(): ?string
    {
        foreach ([
            '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
            '/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
            '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
            '/usr/share/fonts/liberation/LiberationSans-Bold.ttf',
            '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
            '/Library/Fonts/Arial Bold.ttf',
            '/System/Library/Fonts/Supplemental/Arial Bold.ttf',
            '/System/Library/Fonts/Helvetica.ttc',
        ] as $p) {
            if (is_file($p)) return $p;
        }
        return null;
    }

    /** A square PWA icon PNG (solid colour tile + optional centred initial). */
    private function pwaPng(int $size, string $bgHex, string $fgHex, string $letter, ?string $ttf): string
    {
        if (!function_exists('imagecreatetruecolor')) return '';
        $im = imagecreatetruecolor($size, $size);
        [$r, $g, $b] = $this->hexRgb($bgHex);
        imagefilledrectangle($im, 0, 0, $size, $size, imagecolorallocate($im, $r, $g, $b));
        if ($letter !== '' && $ttf && function_exists('imagettftext')) {
            [$fr, $fg, $fb] = $this->hexRgb($fgHex);
            $fc = imagecolorallocate($im, $fr, $fg, $fb);
            $fs = $size * 0.5;
            $bb = imagettfbbox($fs, 0, $ttf, $letter);
            $tw = $bb[2] - $bb[0];
            $th = $bb[1] - $bb[7];
            $x = (int) (($size - $tw) / 2 - $bb[0]);
            $y = (int) (($size - $th) / 2 - $bb[7]);
            imagettftext($im, $fs, 0, $x, $y, $fc, $ttf, $letter);
        }
        ob_start();
        imagepng($im);
        return (string) ob_get_clean();
    }

    /** Resize an uploaded raster (png/jpg/webp) into a square $size PNG (cover-fit). */
    private function pwaResize(string $src, int $size): string
    {
        if (!function_exists('imagecreatetruecolor')) return '';
        $ext = strtolower(pathinfo($src, PATHINFO_EXTENSION));
        $img = match ($ext) {
            'png'         => @imagecreatefrompng($src),
            'jpg', 'jpeg' => @imagecreatefromjpeg($src),
            'webp'        => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($src) : false,
            default       => false,
        };
        if (!$img) return '';
        $sw = imagesx($img);
        $sh = imagesy($img);
        if ($sw < 1 || $sh < 1) return '';
        $dst = imagecreatetruecolor($size, $size);
        imagealphablending($dst, false);
        imagesavealpha($dst, true);
        $scale = max($size / $sw, $size / $sh);           // cover
        $nw = (int) round($sw * $scale);
        $nh = (int) round($sh * $scale);
        imagecopyresampled($dst, $img, (int) (($size - $nw) / 2), (int) (($size - $nh) / 2), 0, 0, $nw, $nh, $sw, $sh);
        ob_start();
        imagepng($dst);
        return (string) ob_get_clean();
    }

    /** Minimal service worker: network-first pages (fresh DB data), SWR static assets. */
    private function serviceWorkerJs(): string
    {
        return <<<'JS'
/* Generated PWA service worker.
   Pages  : network-first, cache the response, fall back to cache → offline page.
   Assets : stale-while-revalidate.
   Push   : show notifications; focus/open the app on click. */
var CACHE = 'ppapp-v2';
var OFFLINE_HTML = '<!doctype html><meta charset="utf-8">'
    + '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
    + '<style>html{color-scheme:light dark}body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
    + 'display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;padding:2rem;text-align:center;'
    + 'background:#0b0d12;color:#e7e9f0}h1{font-size:1.25rem;margin:0 0 .4rem}p{opacity:.7;margin:0}'
    + 'i{font-size:2.2rem;opacity:.5;display:block;margin-bottom:1rem}</style>'
    + '<body><div><div style="font-size:2.4rem">⚡</div><h1>You’re offline</h1>'
    + '<p>This page isn’t cached yet — reconnect to load it.</p></div></body>';

self.addEventListener('install', function (e) { self.skipWaiting(); });
self.addEventListener('activate', function (e) {
    e.waitUntil(caches.keys().then(function (ks) {
        return Promise.all(ks.filter(function (k) { return k !== CACHE; }).map(function (k) { return caches.delete(k); }));
    }).then(function () { return self.clients.claim(); }));
});
self.addEventListener('fetch', function (e) {
    var req = e.request;
    if (req.method !== 'GET') return;
    var url;
    try { url = new URL(req.url); } catch (x) { return; }
    if (url.origin !== self.location.origin) return;
    if (req.mode === 'navigate') {
        e.respondWith(
            fetch(req).then(function (res) {
                if (res && res.ok) { var cp = res.clone(); caches.open(CACHE).then(function (c) { c.put(req, cp); }); }
                return res;
            }).catch(function () {
                return caches.match(req).then(function (hit) {
                    return hit || caches.match('./').then(function (root) {
                        return root || new Response(OFFLINE_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
                    });
                });
            })
        );
        return;
    }
    if (/\.(?:css|js|png|jpe?g|svg|gif|webp|woff2?|ttf|ico)$/.test(url.pathname)) {
        e.respondWith(caches.open(CACHE).then(function (c) {
            return c.match(req).then(function (hit) {
                var net = fetch(req).then(function (res) { if (res && res.ok) c.put(req, res.clone()); return res; }).catch(function () { return hit; });
                return hit || net;
            });
        }));
    }
});
/* Web Push: display the notification and route taps back into the app. */
self.addEventListener('push', function (e) {
    var data = {}; try { data = e.data ? e.data.json() : {}; } catch (x) { data = { body: e.data ? e.data.text() : '' }; }
    var title = data.title || 'Notification';
    e.waitUntil(self.registration.showNotification(title, {
        body: data.body || '', icon: data.icon || 'assets/icon-192.png', badge: 'assets/icon-192.png',
        data: { url: data.url || './' }, tag: data.tag
    }));
});
self.addEventListener('notificationclick', function (e) {
    e.notification.close();
    var target = (e.notification.data && e.notification.data.url) || './';
    e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (cs) {
        for (var i = 0; i < cs.length; i++) { if ('focus' in cs[i]) { cs[i].focus(); try { cs[i].navigate(target); } catch (x) {} return; } }
        if (self.clients.openWindow) return self.clients.openWindow(target);
    }));
});
JS;
    }

    /** Web Push subscription store (emitted only when a VAPID key is configured). */
    private function pushSubscribePhp(): string
    {
        return <<<'PHP'
<?php
/* Persists a Web Push subscription (posted by app-mobile.js) to config/push-subs.json.
   Sending notifications is a separate server step: it needs the VAPID PRIVATE key and a
   Web Push sender (VAPID JWT + payload encryption), typically driven by a cron job. */
header('Content-Type: application/json');
$sub = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($sub) || empty($sub['endpoint'])) { http_response_code(400); echo json_encode(['ok' => false]); exit; }
$dir = __DIR__ . '/../config';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$file = $dir . '/push-subs.json';
$list = is_file($file) ? (json_decode((string) file_get_contents($file), true) ?: []) : [];
$list[hash('sha256', (string) $sub['endpoint'])] = ['sub' => $sub, 'at' => date('c')];
file_put_contents($file, json_encode($list, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
echo json_encode(['ok' => true]);
PHP;
    }

    /** pp_push() helper emitted into the generated app. */
    private function pushHelperPhp(): string
    {
        return <<<'PHP'
<?php
/* Web Push helper for this app.
   Usage (from any server-side code — an event handler, a cron, etc.):
     require_once __DIR__ . '/push/push.php';
     pp_push(['title' => 'New order', 'body' => 'Order #123 received', 'url' => './generated/orders/list.php']);
   Broadcasts to every stored subscription and prunes ones the push service reports gone. */
require_once __DIR__ . '/WebPush.php';
function pp_push_config(): array { $f = __DIR__ . '/../config/push.php'; return is_file($f) ? (array) include $f : []; }
function pp_push_subs_file(): string { return __DIR__ . '/../config/push-subs.json'; }
function pp_push_subs(): array { $f = pp_push_subs_file(); return is_file($f) ? (array) (json_decode((string) file_get_contents($f), true) ?: []) : []; }
function pp_push(array $payload): array {
    $c = pp_push_config();
    if (empty($c['publicKey']) || empty($c['privateKey'])) return ['sent' => 0, 'failed' => 0, 'error' => 'VAPID keys not configured'];
    $subs = pp_push_subs();
    if (!$subs) return ['sent' => 0, 'failed' => 0, 'error' => 'no subscribers yet'];
    $pusher = new PpWebPush($c['publicKey'], $c['privateKey'], $c['subject'] ?? '');
    $res = $pusher->sendAll($subs, $payload);
    if (!empty($res['expired'])) {
        foreach ($res['expired'] as $k) unset($subs[$k]);
        file_put_contents(pp_push_subs_file(), json_encode($subs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
    }
    return $res;
}
PHP;
    }

    /** Test-sender emitted into the generated app (CLI, or web with the config token). */
    private function pushSendTestPhp(): string
    {
        return <<<'PHP'
<?php
/* Send a test push to every subscriber.
     CLI:  php push/send-test.php
     Web:  push/send-test.php?token=<the "token" value in config/push.php>
   The web form requires the token so it can't be triggered anonymously. */
require_once __DIR__ . '/push.php';
$cli = (PHP_SAPI === 'cli');
$cfg = pp_push_config();
if (!$cli) {
    header('Content-Type: application/json');
    $token = (string) ($_GET['token'] ?? $_POST['token'] ?? '');
    if (empty($cfg['token']) || !hash_equals((string) $cfg['token'], $token)) { http_response_code(403); echo json_encode(['ok' => false, 'error' => 'bad or missing token']); exit; }
}
$res = pp_push(['title' => 'Test notification', 'body' => 'Your Web Push setup works! 🎉', 'url' => './']);
if ($cli) echo 'Push result: ' . json_encode($res) . "\n";
else echo json_encode(['ok' => empty($res['error']), 'result' => $res]);
PHP;
    }

    /** Apply the configured file permission mode to every generated file. */
    public function applyFilePermissions(string $outputDir, string $mode): void
    {
        $perm = octdec(preg_replace('/[^0-7]/', '', $mode) ?: '0644');
        if ($perm < 0400 || $perm > 0777) $perm = 0644;
        $this->log('Applying file permissions: 0' . decoct($perm));

        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($it as $file) {
            if ($file->isFile()) @chmod($file->getPathname(), $perm);
        }
    }

    public function generateDesignerCss(string $outputDir): array
    {
        $this->log('Generating designer component styles…');
        $cssDir = $outputDir . '/assets/css';
        if (!is_dir($cssDir)) mkdir($cssDir, 0755, true);

        $cssFile = $cssDir . '/designer-base.css';
        $css = <<<'CSS'
/* ── Application Shell ── */
.ppd-application-shell { display: flex; min-height: 100vh; }
.ppd-shell-sidebar { width: 250px; background:var(--pp-surface-raised,#1e293b); color: var(--pp-sidebar-text, #cbd5e1); padding: 1rem 0; flex-shrink: 0; overflow-y: auto; }
.ppd-shell-sidebar .nav { list-style: none; padding: 0; margin: 0; }
.ppd-shell-sidebar .nav-item { border-bottom:1px solid var(--pp-border-strong,#334155); }
.ppd-shell-sidebar .nav-item.active { background:var(--pp-border-strong,#334155); }
.ppd-shell-sidebar .nav-item.active .nav-link { color:var(--pp-primary-text,#fff); }
.ppd-shell-sidebar .nav-link { display: block; padding: 0.75rem 1.25rem; color:var(--pp-muted,#94a3b8); text-decoration: none; transition: color .15s; }
.ppd-shell-sidebar .nav-link:hover { color:var(--pp-primary-text,#fff); }
.ppd-shell-main { flex: 1; padding: 1.5rem 2rem; background:var(--pp-surface-soft,#f8fafc); min-width: 0; }
.ppd-shell-main header { margin-bottom: 1.5rem; }
.ppd-shell-main header h1 { margin: 0; font-size: 1.75rem; font-weight: 600; color:var(--pp-text,#0f172a); }
.ppd-shell-main section { background:var(--pp-surface,#fff); border-radius: 0.5rem; box-shadow: var(--pp-shadow-sm, 0 1px 3px rgba(0,0,0,.1)); padding: 1.5rem; }

/* ── Dashboard ── */
.ppd-dashboard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.ppd-dashboard-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.ppd-dashboard-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 1rem; }
.ppd-dashboard-widget { background:var(--pp-surface,#fff); border:1px solid var(--pp-border,#e2e8f0); border-radius: 0.5rem; padding: 1.25rem; }
.ppd-span-3 { grid-column: span 3; }
.ppd-span-4 { grid-column: span 4; }
.ppd-span-6 { grid-column: span 6; }
.ppd-span-12 { grid-column: span 12; }
.ppd-stat-icon { font-size: 2rem; color: var(--pp-primary, #6366f1); margin-bottom: 0.5rem; }
.ppd-stat-body span { display: block; font-size: 0.875rem; color:var(--pp-muted,#64748b); text-transform: uppercase; letter-spacing: .05em; }
.ppd-stat-body strong { font-size: 1.75rem; font-weight: 700; color:var(--pp-text,#0f172a); }
.ppd-chart-placeholder { display: flex; align-items: center; justify-content: center; height: 200px; background:var(--pp-surface-soft,#f1f5f9); border-radius: 0.375rem; color:var(--pp-muted,#94a3b8); font-size: 0.875rem; }
.ppd-activity-feed { list-style: none; padding: 0; }
.ppd-activity-feed li { padding: 0.75rem 0; border-bottom:1px solid var(--pp-border,#f1f5f9); color:var(--pp-text,#475569); }

/* ── Data Grid ── */
.ppd-grid-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.ppd-grid-toolbar h3 { margin: 0; margin-right: auto; font-size: 1.25rem; font-weight: 600; }
.ppd-grid-search { padding: 0 0.75rem; border: 1px solid var(--pp-border, #cbd5e1); border-radius: 0.5rem; font-size: 0.85rem; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); }
.ppd-grid-search:focus { outline: none; border-color: var(--pp-primary, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary, #6366f1) 16%, transparent); }
.ppd-grid-toolbar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; padding: 0 0.5rem; background: var(--pp-surface, #fff); color: var(--pp-text, #334155); border: 1px solid var(--pp-border, #cbd5e1); border-radius: 0.5rem; cursor: pointer; font-size: 0.82rem; font-weight: 500; white-space: nowrap; text-decoration: none; transition: background .14s, border-color .14s, color .14s; }
/* icon-only toolbar buttons (print/pdf/refresh/density/columns/spreadsheet/view-toggle)
   render as tidy squares — they all carry data-pp-tip; labeled buttons never do */
.ppd-grid-toolbar-btn[data-pp-tip]:not(.pp-tb-collapsible) { padding: 0; width: 32px; }
.ppd-grid-toolbar-btn:hover { background: var(--pp-surface-soft, #f1f5f9); border-color: var(--pp-primary, #6366f1); color: var(--pp-primary, #6366f1); }
.ppd-grid-toolbar-btn.is-active { background: var(--pp-primary, #6366f1); color:var(--pp-primary-text,#fff); border-color: var(--pp-primary, #6366f1); }

/* toolbar controls share one height (selects render shorter than inputs
   otherwise); a single fixed height keeps every control on a clean baseline */
.ppd-grid-search, .ppd-grid-toolbar-btn { box-sizing: border-box; height: 34px; vertical-align: middle; }
select.ppd-grid-search { padding: 0 1.6rem 0 .7rem; line-height: 1.35; }

/* ── Master–Detail details links (list page) ── */
.ppd-grid-details { white-space: nowrap; }
.pp-md-link { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; }
.pp-md-link .badge { line-height: 1.3; vertical-align: middle; }
.pp-md-link .pp-md-caret { font-size: 0.75em; transition: transform .15s ease; }

/* ── Master–Detail preview & embedded detail grid (theme-aware) ── */
.pp-md-exp > td { background: var(--pp-surface-soft, #f8fafc); color: var(--pp-text, #0f172a); }
.pp-detail-grid, .pp-md-preview {
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius, 12px);
  background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); padding: 14px; }
.pp-md-preview { padding: 12px 14px; }
.pp-md-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; color: var(--pp-text, #0f172a); }
.pp-md-head strong { color: inherit; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.pp-md-head .badge.bg-secondary { background: var(--pp-muted, #64748b) !important; color:var(--pp-primary-text,#fff); }
.pp-detail-grid .ppd-grid-table, .pp-md-preview .ppd-grid-table { color: var(--pp-text, #0f172a); }
.ppd-grid-table.pp-compact th, .ppd-grid-table.pp-compact td { padding-top: .3rem; padding-bottom: .3rem; }

/* ── View page: professional record card ── */
.pp-record { max-width: 980px; }
.pp-record-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 1.1rem; }
.pp-record-avatar { width: 46px; height: 46px; border-radius: 12px; flex: none; display: flex; align-items: center; justify-content: center;
  font-size: 1.15rem; font-weight: 800; color: var(--pp-primary-text, #fff); background: var(--pp-primary, #2563eb); }
.pp-record-titles { min-width: 0; }
.pp-record-title { font-size: 1.2rem; font-weight: 700; color: var(--pp-text, #0f172a); line-height: 1.2; }
.pp-record-sub { font-size: .82rem; color: var(--pp-muted, #64748b); }
.pp-record-head .pp-record-actions { margin-left: auto; display: flex; gap: .5rem; flex-wrap: wrap; }
.pp-record-card { border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius, 12px);
  background: var(--pp-surface, #fff); overflow: hidden; box-shadow: var(--pp-shadow-sm, 0 1px 2px rgba(15,23,42,.04)); }
.pp-record-card + .pp-record-card, .pp-record-card + .pp-detail-grid, .pp-detail-grid + .pp-detail-grid { margin-top: 1.1rem; }
.pp-record-card-h { padding: .75rem 1.15rem; border-bottom: 1px solid var(--pp-border, #e2e8f0); font-weight: 700;
  font-size: .95rem; color: var(--pp-text, #0f172a); display: flex; align-items: center; gap: 8px; background: var(--pp-surface-soft, #f8fafc); }
.pp-dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pp-dl > div { display: flex; flex-direction: column; gap: 4px; padding: .72rem 1.15rem; border-bottom: 1px solid var(--pp-border, #eef2f7); min-width: 0; }
.pp-dl > div:nth-last-child(-n+2) { border-bottom: 0; }
.pp-dl > div.pp-dl-full { grid-column: 1 / -1; }
.pp-dl dt { font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; color: var(--pp-muted, #64748b); font-weight: 600; margin: 0; }
.pp-dl dd { margin: 0; font-size: .92rem; color: var(--pp-text, #0f172a); font-weight: 500; word-break: break-word; }
.pp-dl dd:empty::after { content: '—'; color: var(--pp-muted, #94a3b8); }
@media (max-width: 640px) { .pp-dl { grid-template-columns: 1fr; } .pp-dl > div { border-bottom: 1px solid var(--pp-border, #eef2f7) !important; } }

/* ── Print page: branded, paper-clean ── */
.pp-print { color: #111; }
.pp-print-header { display: flex; align-items: center; gap: 14px; padding-bottom: .9rem; margin-bottom: 1.1rem; border-bottom: 2px solid #222; }
.pp-print-logo { height: 42px; width: auto; }
.pp-print-mark { width: 42px; height: 42px; border-radius: 9px; background: #222; color:#fff; display: flex; align-items: center; justify-content: center; font-weight: 800; }
.pp-print-org { font-size: 1.05rem; font-weight: 800; }
.pp-print-title { font-size: 1.35rem; font-weight: 700; margin: 0; }
.pp-print-meta { color: #555; font-size: .8rem; margin-top: .15rem; }
.pp-print-head-right { margin-left: auto; text-align: right; }
.pp-print table { width: 100%; border-collapse: collapse; font-size: .82rem; }
.pp-print th, .pp-print td { border:1px solid var(--pp-border,#cbd5e1); padding: 6px 9px; text-align: left; vertical-align: top; }
.pp-print thead th { background: #eef2f7; font-weight: 700; }
.pp-print tbody tr:nth-child(even) td { background:var(--pp-surface-soft,#f8fafc); }
.pp-print-footer { margin-top: 1.4rem; padding-top: .6rem; border-top:1px solid var(--pp-border,#cbd5e1); color: #666; font-size: .75rem; display: flex; justify-content: space-between; }
.pp-print .pp-dl { border:1px solid var(--pp-border,#cbd5e1); border-radius: 6px; }
.pp-print .pp-dl > div { border-bottom:1px solid var(--pp-border,#e2e8f0); }
.pp-print .pp-dl dt { color: #555; }
.pp-print .pp-dl dd { color: #111; }
@media print { .pp-no-print { display: none !important; } .pp-print { margin: 0; } .pp-print-record { page-break-inside: avoid; } body { background:var(--pp-surface,#fff); } }

/* ── Import wizard ── */
.pp-import { max-width: 900px; }
.pp-steps { list-style: none; display: flex; gap: .5rem; padding: 0; margin: 0 0 1.2rem; flex-wrap: wrap; }
.pp-steps li { display: flex; align-items: center; gap: .5rem; font-size: .85rem; font-weight: 600; color: var(--pp-muted, #94a3b8); padding: .35rem .8rem; border-radius: 999px; border: 1px solid var(--pp-border, #e2e8f0); }
.pp-steps li span { width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: .72rem; background: var(--pp-surface-soft, #f1f5f9); color: var(--pp-muted, #64748b); }
.pp-steps li.is-active { color: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb); background: var(--pp-primary-soft, #eff6ff); }
.pp-steps li.is-active span { background: var(--pp-primary, #2563eb); color: var(--pp-primary-text, #fff); }
.pp-steps li.is-done { color: var(--pp-success, #16a34a); border-color: color-mix(in srgb, var(--pp-success, #16a34a) 40%, transparent); }
.pp-steps li.is-done span { background: var(--pp-success, #16a34a); color:var(--pp-primary-text,#fff); }
.pp-import-body { padding: 1.15rem; }
.pp-drop { display: block; border: 2px dashed var(--pp-border, #cbd5e1); border-radius: 12px; padding: 1.6rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; }
.pp-drop:hover, .pp-drop.drag { border-color: var(--pp-primary, #2563eb); background: var(--pp-primary-soft, #eff6ff); }
.pp-drop input[type=file] { display: block; margin: 0 auto .6rem; }
.pp-drop-inner { display: flex; flex-direction: column; gap: 3px; color: var(--pp-muted, #64748b); }
.pp-drop-inner i { font-size: 1.8rem; color: var(--pp-primary, #2563eb); }
.pp-drop-inner b { color: var(--pp-text, #0f172a); }
.pp-check { display: inline-flex; align-items: center; gap: 8px; margin-top: .9rem; font-size: .9rem; color: var(--pp-text, #334155); }
.pp-import-actions { display: flex; gap: .6rem; align-items: center; margin-top: 1.1rem; flex-wrap: wrap; }
.pp-map-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .7rem 1.1rem; }
.pp-map-grid label { display: flex; flex-direction: column; gap: 4px; }
.pp-map-grid label span { font-size: .78rem; font-weight: 600; color: var(--pp-text, #334155); }
.pp-map-grid select { width: 100%; }
@media (max-width: 640px) { .pp-map-grid { grid-template-columns: 1fr; } }
/* Import source tabs (Copy/Paste | Upload File) — mirrors the Designer's
   default Import template, which offers both sources. */
/* Data menu: a rule and a small caption separating the two transfer screens
   from the one-click downloads underneath them. */
.pp-menu-sep { height: 1px; background: var(--pp-border, #e2e8f0); margin: .35rem 0; }
.pp-menu-cap { font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--pp-muted, #94a3b8); padding: .3rem .75rem .15rem; }
.pp-import-lead { color: var(--pp-muted, #64748b); margin: -.4rem 0 1rem; font-size: .92rem; }
.pp-imp-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--pp-border, #e2e8f0); margin-bottom: 1rem; }
.pp-imp-tab { appearance: none; background: none; border: 0; border-bottom: 2px solid transparent; padding: .55rem .9rem; font-size: .9rem; font-weight: 600; color: var(--pp-muted, #64748b); cursor: pointer; }
.pp-imp-tab:hover { color: var(--pp-text, #0f172a); }
.pp-imp-tab.is-active { color: var(--pp-primary, #2563eb); border-bottom-color: var(--pp-primary, #2563eb); }
.pp-imp-pane { display: none; }
.pp-imp-pane.is-active { display: block; }
.pp-paste { width: 100%; min-height: 170px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; padding: .7rem .8rem; border: 1px solid var(--pp-border, #cbd5e1); border-radius: 10px; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); resize: vertical; }
.pp-opt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem 1.1rem; }
.pp-opt-grid label { display: flex; flex-direction: column; gap: 4px; }
.pp-opt-grid label span { font-size: .78rem; font-weight: 600; color: var(--pp-text, #334155); }
.pp-opt-grid .pp-check { margin-top: 0; flex-direction: row; align-items: center; }
@media (max-width: 640px) { .pp-opt-grid { grid-template-columns: 1fr; } }
.pp-sum-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .7rem; margin: 0 0 1rem; }
.pp-sum-tile { border: 1px solid var(--pp-border, #e2e8f0); border-radius: 10px; padding: .6rem .8rem; background: var(--pp-surface-soft, #f8fafc); }
.pp-sum-tile b { display: block; font-size: 1.15rem; color: var(--pp-text, #0f172a); }
.pp-sum-tile span { font-size: .75rem; color: var(--pp-muted, #64748b); }
.pp-sum-tile.warn b { color: var(--pp-warning, #d97706); }
.pp-sum-tile.err b { color: var(--pp-danger, #dc2626); }
@media (max-width: 640px) { .pp-sum-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.pp-import-preview .ppd-grid-table { font-size: .8rem; }
.pp-import-summary { display: flex; gap: 1rem; margin-bottom: .8rem; }
.pp-sum-ok, .pp-sum-fail { padding: .6rem 1rem; border-radius: 10px; font-size: .9rem; }
.pp-sum-ok { background: color-mix(in srgb, var(--pp-success, #16a34a) 14%, transparent); color: var(--pp-success, #16a34a); }
.pp-sum-ok b, .pp-sum-fail b { font-size: 1.2rem; }
.pp-sum-fail { background: color-mix(in srgb, var(--pp-danger, #dc2626) 14%, transparent); color: var(--pp-danger, #dc2626); }
.pp-import-errs { margin: .3rem 0 0; padding-left: 1.2rem; color: var(--pp-danger, #b91c1c); font-size: .82rem; }
.ppd-grid-bulk-actions { margin-bottom: 0.75rem; display: flex; gap: 0.5rem; }
.ppd-grid-bulk-actions button { padding: 0.25rem 0.5rem; background:var(--pp-danger-soft,#fef2f2); border:1px solid var(--pp-danger-border,#fca5a5); border-radius: 0.25rem; cursor: pointer; font-size: 0.75rem; color:var(--pp-danger-text,#991b1b); }
.ppd-grid-table { width: 100%; border-collapse: collapse; }
.ppd-grid-table th, .ppd-grid-table td { text-align: left; padding: 0.625rem 0.75rem; border-bottom:1px solid var(--pp-border,#e2e8f0); }
.ppd-grid-table th { background:var(--pp-surface-soft,#f8fafc); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: .05em; color:var(--pp-muted,#64748b); }
.ppd-grid-select, .ppd-grid-actions { width: 1px; white-space: nowrap; }
.ppd-grid-pagination { display: flex; align-items: center; gap: 0.5rem; padding-top: 1rem; margin-top: 1rem; border-top:1px solid var(--pp-border,#e2e8f0); font-size: 0.875rem; color:var(--pp-muted,#64748b); }
.ppd-grid-total { margin-left: auto; }

/* ── List Settings (Pages module) — scroll / reorder / resize / click ── */
/* Scroll table data (fixed header): bounded scroll region, thead stays put */
.pp-scroll-table { max-height: 60vh; overflow: auto; border: 1px solid var(--pp-border, #e2e8f0); border-radius: 10px; }
.pp-scroll-table .ppd-grid-table thead th { position: sticky; top: 0; z-index: 3; }
/* Reorder columns: a grip handle fades in on header hover so the "drag to
   rearrange" affordance is obvious; grab/grabbing cursors + a clear insertion bar. */
.pp-reorderable thead th[data-col] { cursor: grab; }
.pp-reorderable thead th[data-col]:active { cursor: grabbing; }
.pp-col-grip { display: inline-flex; align-items: center; vertical-align: middle; margin-right: 5px;
    color: var(--pp-muted, #94a3b8); opacity: 0; transition: opacity .12s, color .12s; cursor: grab; font-size: .9em; }
.pp-reorderable thead th[data-col]:hover .pp-col-grip { opacity: .75; }
.pp-col-grip:hover { color: var(--pp-primary, #6366f1); opacity: 1; }
.pp-reorderable thead th[data-col].pp-col-dragging { opacity: .4; }
.pp-reorderable thead th[data-col].pp-col-over-left  { box-shadow: inset  3px 0 0 0 var(--pp-primary, #6366f1); }
.pp-reorderable thead th[data-col].pp-col-over-right { box-shadow: inset -3px 0 0 0 var(--pp-primary, #6366f1); }
/* Floating "chip" that follows the cursor while dragging a column */
.pp-col-drag-ghost { position: fixed; z-index: 2100; pointer-events: none; padding: 4px 10px; border-radius: 8px;
    background: var(--pp-primary, #6366f1); color: var(--pp-primary-text, #fff); font-size: .78rem; font-weight: 600;
    box-shadow: 0 8px 22px rgba(2,6,23,.28); display: inline-flex; align-items: center; gap: 6px; opacity: .96; }
/* Resizable columns: a slim handle at the column edge that brightens on hover,
   plus a full-height guide line during the drag. */
.pp-resizable thead th { position: relative; }
.pp-col-resizer { position: absolute; top: 0; right: -4px; width: 10px; height: 100%; cursor: col-resize;
    user-select: none; touch-action: none; z-index: 5; display: flex; align-items: center; justify-content: center; }
.pp-col-resizer::before { content: ''; width: 2px; height: 55%; border-radius: 2px; background: var(--pp-border, #cbd5e1);
    transition: background .12s, height .12s; }
.pp-col-resizer:hover::before, .pp-col-resizing .pp-col-resizer::before { background: var(--pp-primary, #6366f1); height: 82%; }
body.pp-col-resizing { cursor: col-resize !important; user-select: none; }
.pp-resize-guide { position: fixed; top: 0; width: 2px; z-index: 2000; pointer-events: none;
    background: var(--pp-primary, #6366f1); box-shadow: 0 0 0 1px color-mix(in srgb, var(--pp-primary, #6366f1) 35%, transparent); }
/* Click actions: cells/rows that trigger an action */
.pp-click-row tbody tr[data-id] { cursor: pointer; }
/* Keyboard a11y — the whole row is focusable when it is a click target; give it
   a clear focus ring (only for keyboard focus, not a mouse click). */
.pp-click-row tbody tr[data-id][tabindex]:focus-visible { outline: 2px solid var(--pp-primary, #2563eb); outline-offset: -2px; }
.pp-click-row tbody tr[data-id][tabindex]:focus:not(:focus-visible) { outline: none; }
.ppd-grid-table td.pp-click-cell { cursor: pointer; }
.ppd-grid-table td.pp-click-cell:hover { color: var(--pp-primary, #2563eb); text-decoration: underline; }
/* Row numbers, table caption, and the visually-hidden a11y caption/text. */
.ppd-grid-table .pp-rownum { text-align: right; width: 1%; white-space: nowrap; color: var(--pp-muted, #94a3b8); font-variant-numeric: tabular-nums; }
.pp-grid-caption { caption-side: top; text-align: left; font-weight: 700; padding: .35rem .15rem .5rem; color: var(--pp-text, #1e293b); }
.pp-sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
/* Conditional-formatting badge pill (grid rule with the "badge" style on). */
.pp-cf-badge { display: inline-block; padding: .12em .6em; border-radius: 999px; font-size: .82em; font-weight: 600; line-height: 1.5; background: var(--pp-muted, #64748b); color: #fff; }
/* Cap very wide cells in nowrap mode so one long value can't force the whole
   table into horizontal scroll (textarea cells also truncate + carry a title). */
.ppd-grid-table.pp-nowrap td { max-width: 340px; overflow: hidden; text-overflow: ellipsis; }
/* Responsive "stack" mode (grid responsive=stack): on narrow screens each row
   collapses into a labelled card — every cell shows its column header via the
   data-label attribute. Below the breakpoint the header row is hidden. */
@media (max-width: 640px) {
  .ppd-grid-table.pp-resp-stack { display: block; }
  .ppd-grid-table.pp-resp-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
  .ppd-grid-table.pp-resp-stack tbody { display: block; }
  .ppd-grid-table.pp-resp-stack tbody tr { display: block; margin: 0 0 .6rem; border: 1px solid var(--pp-border, #e2e8f0); border-radius: 8px; padding: .35rem .55rem; background: var(--pp-surface, #fff); }
  .ppd-grid-table.pp-resp-stack tbody td { display: flex; justify-content: space-between; gap: 1rem; max-width: none; padding: .3rem .1rem; border: 0; text-align: right; }
  .ppd-grid-table.pp-resp-stack tbody td[data-label]::before { content: attr(data-label); font-weight: 600; color: var(--pp-muted, #64748b); text-align: left; }
  .ppd-grid-table.pp-resp-stack tbody td.ppd-grid-select, .ppd-grid-table.pp-resp-stack tbody td.pp-rownum { justify-content: flex-start; }
  .ppd-grid-table.pp-resp-stack tbody td.ppd-grid-actions { justify-content: flex-end; }
}
/* Empty state — distinguishes "nothing yet" from "filters matched nothing". */
.pp-empty-state { text-align: center; padding: 2.4rem 1rem; color: var(--pp-muted, #64748b); display: flex; flex-direction: column; align-items: center; gap: .5rem; }
.pp-empty-state .pp-empty-ic { font-size: 2rem; opacity: .55; }
.pp-empty-state .pp-empty-msg { margin: 0; font-size: .95rem; }
/* Keyboard focus for the grid's interactive chrome (was weak/absent). */
.ppd-grid-toolbar-btn:focus-visible, .pp-cols-toggle > summary:focus-visible,
.pp-hf-btn:focus-visible, .pp-sf-trigger:focus-visible, .pp-sf-opt:focus-visible,
.pp-act-more:focus-visible, .pp-search-go:focus-visible, .pp-search-clear:focus-visible,
.pp-col-pin:focus-visible { outline: 2px solid var(--pp-primary, #2563eb); outline-offset: 2px; border-radius: 4px; }
/* Respect reduced-motion for the grid's own animations (kanban drag, column
   drag ghost, resize guide, header-filter spinner, active-search pulse). */
@media (prefers-reduced-motion: reduce) {
  .ppd-grid-table *, .pp-kanban, .pp-kanban *, .pp-col-drag-ghost, .pp-hf-spin,
  .pp-search-clear, .pp-act-pop, .pp-cols-menu, .pp-resize-guide {
    animation: none !important; transition: none !important; scroll-behavior: auto !important;
  }
}
/* Freeze / pin columns: a small pin toggle in each header (shown on hover), and
   sticky cells with an opaque background so scrolled content never bleeds through. */
.pp-pinnable thead th[data-col] { position: relative; }
.pp-col-pin { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); z-index: 6;
    border: 0; background: transparent; color: var(--pp-text-muted, #94a3b8); cursor: pointer; padding: 2px;
    line-height: 1; opacity: 0; transition: opacity .12s, color .12s; font-size: .8rem; }
.pp-pinnable thead th[data-col]:hover .pp-col-pin, .pp-col-pin.is-pinned { opacity: 1; }
.pp-col-pin:hover { color: var(--pp-primary, #6366f1); }
.pp-col-pin.is-pinned { color: var(--pp-primary, #6366f1); }
.ppd-grid-table th.pp-pinned, .ppd-grid-table td.pp-pinned { position: sticky; z-index: 3;
    background: var(--pp-surface, #fff); }
.ppd-grid-table thead th.pp-pinned { z-index: 7; background: var(--pp-surface-soft, var(--pp-surface, #f8fafc)); }
.ppd-grid-table tbody tr:hover td.pp-pinned { background: var(--pp-surface-soft, #f1f5f9); }
.ppd-grid-table .pp-pin-edge-left { box-shadow: 2px 0 0 0 var(--pp-border, #e2e8f0); }
.ppd-grid-table .pp-pin-edge-right { box-shadow: -2px 0 0 0 var(--pp-border, #e2e8f0); }
/* Row grouping: a collapsible group header + optional numeric subtotal row. */
.pp-group-row td { background: var(--pp-surface-soft, #f1f5f9); font-weight: 600; padding: .45rem .6rem;
    border-top: 2px solid var(--pp-border, #e2e8f0); position: sticky; left: 0; }
.pp-group-toggle { border: 0; background: transparent; cursor: pointer; color: var(--pp-text-muted, #64748b);
    padding: 0 .45rem 0 0; font-size: .95rem; line-height: 1; }
.pp-group-toggle:hover { color: var(--pp-primary, #6366f1); }
.pp-group-label { color: var(--pp-text, #0f172a); }
.pp-group-count { display: inline-block; margin-left: .55rem; min-width: 1.4em; text-align: center;
    padding: .05rem .45rem; border-radius: 999px; background: var(--pp-primary, #6366f1);
    color: var(--pp-on-accent, #fff); font-size: .72rem; font-weight: 700; }
.pp-group-subtotal td { background: color-mix(in srgb, var(--pp-primary, #6366f1) 6%, var(--pp-surface, #fff));
    border-bottom: 1px solid var(--pp-border, #e2e8f0); font-size: .82rem; color: var(--pp-text, #0f172a); }
/* Saved views: a toolbar dropdown of named list presets (search + sort + filters + columns). */
.pp-views { position: relative; }
/* icon-only Saved-views button; active state = accent ring + primary tint */
.pp-views-btn { position: relative; }
.pp-views-btn.pp-views-on { border-color: var(--pp-primary, #6366f1); color: var(--pp-primary, #6366f1); background: color-mix(in srgb, var(--pp-primary, #6366f1) 8%, var(--pp-surface, #fff)); }
.pp-views-btn.pp-views-on:hover { background: color-mix(in srgb, var(--pp-primary, #6366f1) 14%, var(--pp-surface, #fff)); }
/* active-view dot indicator (a saved view is applied) */
.pp-views-btn.pp-views-on::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--pp-primary, #6366f1); }
.pp-views-cur { display: none; }
.pp-views-menu { position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 224px;
    background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e2e8f0); border-radius: 10px;
    box-shadow: var(--pp-shadow-lg, 0 12px 32px rgba(2,6,23,.18)); padding: 6px; }
.pp-views-menu[hidden] { display: none; }
.pp-views-empty { padding: 8px 10px; color: var(--pp-muted, #64748b); font-size: .82rem; }
.pp-views-item { display: flex; align-items: center; justify-content: space-between; border-radius: 7px; }
.pp-views-item:hover { background: var(--pp-surface-soft, #f1f5f9); }
.pp-views-item.is-active .pp-views-apply { color: var(--pp-primary, #6366f1); font-weight: 600; }
.pp-views-apply { flex: 1; padding: 6px 8px; cursor: pointer; font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pp-views-del { border: 0; background: transparent; color: var(--pp-muted, #94a3b8); cursor: pointer; padding: 4px 6px; border-radius: 6px; line-height: 1; }
.pp-views-del:hover { color: var(--pp-danger, #dc2626); background: var(--pp-danger-soft, #fef2f2); }
.pp-views-foot { border-top: 1px solid var(--pp-border, #e2e8f0); margin-top: 4px; padding-top: 4px; display: flex; flex-direction: column; gap: 2px; }
.pp-views-foot button { text-align: left; border: 0; background: transparent; cursor: pointer; padding: 6px 8px; border-radius: 7px; font-size: .82rem; color: var(--pp-text, #0f172a); }
.pp-views-foot button:hover { background: var(--pp-surface-soft, #f1f5f9); color: var(--pp-primary, #6366f1); }
/* Kanban / board view: columns of draggable cards grouped by a status field. */
.pp-kanban { display: flex; gap: 12px; overflow-x: auto; padding: 4px 2px 12px; align-items: flex-start; }
.pp-kanban[hidden] { display: none; }
.pp-kanban-col { flex: 0 0 264px; background: var(--pp-surface-soft, #f1f5f9); border: 1px solid var(--pp-border, #e2e8f0);
    border-radius: 12px; display: flex; flex-direction: column; max-height: 72vh; }
.pp-kanban-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px;
    font-weight: 700; color: var(--pp-text, #0f172a); border-bottom: 1px solid var(--pp-border, #e2e8f0); }
.pp-kanban-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-kanban-num { background: var(--pp-primary, #6366f1); color: var(--pp-on-accent, #fff); border-radius: 999px;
    min-width: 1.5em; text-align: center; padding: .05rem .5rem; font-size: .72rem; flex: 0 0 auto; }
.pp-kanban-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; min-height: 44px; }
.pp-kanban-col.is-over .pp-kanban-body { outline: 2px dashed var(--pp-primary, #6366f1); outline-offset: -4px; border-radius: 8px; }
.pp-kanban-card { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e2e8f0); border-radius: 10px;
    padding: 10px 12px; cursor: grab; box-shadow: 0 1px 2px rgba(2, 6, 23, .06); }
.pp-kanban-card:active { cursor: grabbing; }
.pp-kanban-card.is-dragging { opacity: .5; }
.pp-kanban-ct { font-weight: 600; color: var(--pp-text, #0f172a); margin-bottom: 4px; }
.pp-kanban-cd { font-size: .78rem; color: var(--pp-text-muted, #64748b); }
.pp-kanban-cd span { color: var(--pp-muted, #94a3b8); }
/* Responsive: on tablets narrow the board columns; on phones each column nearly
   fills the viewport so users swipe between them (matches the device previews). */
@media (max-width: 1024px) { .pp-kanban-col { flex-basis: 240px; } }
@media (max-width: 640px) {
    .pp-kanban-col { flex-basis: 82vw; max-height: none; }
    .pp-kanban { scroll-snap-type: x mandatory; }
    .pp-kanban-col { scroll-snap-align: start; }
    .pp-views-menu { min-width: min(224px, 88vw); }
    .pp-group-count { margin-left: .35rem; }
}
/* Add-in-popup: iframe modal */
.pp-form-modal { position: fixed; inset: 0; z-index: 1080; display: flex; align-items: center; justify-content: center; background:var(--pp-scrim,rgba(15, 23, 42, .55)); padding: 1.5rem; }
.pp-form-modal[hidden] { display: none; }
.pp-form-modal-dialog { background: var(--pp-surface, #fff); border-radius: 14px; box-shadow: var(--pp-shadow-lg, 0 24px 64px rgba(0,0,0,.32)); width: min(920px, 100%); max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; }
.pp-form-modal-head { display: flex; align-items: center; justify-content: space-between; padding: .7rem 1rem; border-bottom: 1px solid var(--pp-border, #e2e8f0); cursor: move; user-select: none; }
.pp-form-modal-head.is-dragging { cursor: grabbing; }
.pp-form-modal-title { font-weight: 700; color: var(--pp-text, #0f172a); }
.pp-form-modal-x { border: 0; background: transparent; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--pp-muted, #64748b); padding: 0 .4rem; border-radius: 8px; }
.pp-form-modal-x:hover { background: var(--pp-surface-soft, #f1f5f9); color: var(--pp-text, #0f172a); }
.pp-form-modal-frame { border: 0; width: 100%; height: 60vh; background: var(--pp-surface, #fff); display: block; }
/* SweetAlert2 must sit ABOVE the Add modal (z 1080) — its default 1060 is below */
.swal2-container { z-index: 100000 !important; }

/* ── View toggle (Table / Cards) ── */
.ppd-view-toggle { display: inline-flex; gap: 0; }
.ppd-view-toggle .ppd-grid-toolbar-btn { border-radius: 0; margin: 0; }
.ppd-view-toggle .ppd-grid-toolbar-btn:first-child { border-radius: .5rem 0 0 .5rem; }
.ppd-view-toggle .ppd-grid-toolbar-btn:last-child { border-radius: 0 .5rem .5rem 0; border-left: 0; }
.ppd-view-toggle .ppd-grid-toolbar-btn.is-active { background: var(--pp-primary, #6366f1); color:var(--pp-primary-text,#fff); border-color: var(--pp-primary, #6366f1); }

/* ── Cards view: injected from the shared public/assets/css/pp-cards.css (the
   single source of truth also used by the Designer canvas). See appendCardCss(). ── */

/* ── Forms ── */
.ppg-form h2 { margin-top: 0; font-size: 1.25rem; font-weight: 600; }
.ppg-form-section { margin-bottom: 1.5rem; border:1px solid var(--pp-border,#e2e8f0); border-radius: 0.375rem; padding: 1rem; }
.ppg-form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1rem; font-weight: 600; color:var(--pp-text,#334155); }
.ppg-form-section label { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
.ppg-form-section label span:not(.pp-req) { min-width: 120px; font-weight: 500; color:var(--pp-text,#475569); }
.ppg-form-section label span.pp-req { min-width: 0; }
.ppg-form-section label input,
.ppg-form-section label select,
.ppg-form-section label textarea { flex: 1; padding: 0.375rem 0.75rem; border:1px solid var(--pp-border,#cbd5e1); border-radius: 0.25rem; font-size: 0.875rem; }

/* ── Auth Pages ── */
.ppd-auth-page { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.ppd-auth-form { width: 100%; max-width: 400px; padding: 2rem; background:var(--pp-surface,#fff); border-radius: 0.5rem; box-shadow: var(--pp-shadow-sm, 0 1px 3px rgba(0,0,0,.1)); }
.ppd-auth-form h2 { margin-top: 0; text-align: center; font-weight: 600; }
.ppd-auth-form label { display: block; margin-bottom: 1rem; }
.ppd-auth-form label span { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.ppd-auth-form label input { width: 100%; padding: 0.5rem 0.75rem; border:1px solid var(--pp-border,#cbd5e1); border-radius: 0.25rem; box-sizing: border-box; }
.ppd-auth-action { width: 100%; padding: 0.625rem; background: var(--pp-primary, #6366f1); color:var(--pp-primary-text,#fff); border: none; border-radius: 0.375rem; cursor: pointer; font-weight: 500; margin-top: 0.5rem; }
.ppd-auth-action:hover { background: var(--pp-primary-hover, #4f46e5); }
.ppd-auth-display { margin-bottom: 1rem; }
.ppd-auth-display span { display: block; font-size: 0.75rem; color:var(--pp-muted,#64748b); text-transform: uppercase; letter-spacing: .05em; }
.ppd-auth-display strong { font-size: 1rem; color:var(--pp-text,#0f172a); }
CSS;
        // Card Gallery styles come from the ONE canonical stylesheet the Designer
        // canvas also uses, so the design preview and the shipped Cards view can
        // never drift. Inlined here (rather than a separate <link>) to avoid
        // touching every generated page head.
        $css .= "\n\n/* ── Cards view (shared: public/assets/css/pp-cards.css) ── */\n" . $this->sharedCardCss();
        file_put_contents($cssFile, $css);
        $this->log('Wrote assets/css/designer-base.css');
        $this->filesWritten[] = $cssFile;

        $written = [$cssFile];
        // Ship the canvas component styles so ComponentRenderer output looks
        // identical to the Designer preview (same .ppd-* classes, --ppd-*
        // tokens the theme.css already emits).
        $compFile = $cssDir . '/components.css';
        file_put_contents($compFile, $this->extractComponentCss());
        $this->log('Wrote assets/css/components.css');
        $this->filesWritten[] = $compFile;
        $written[] = $compFile;

        return $written;
    }

    /**
     * The canonical Card Gallery stylesheet — one source of truth shared with
     * the Designer canvas (modules/Designer/Views/index.php inlines the same
     * file). Read at build time so the shipped Cards view and the design preview
     * stay byte-identical.
     */
    private function sharedCardCss(): string
    {
        $src = dirname(__DIR__, 2) . '/public/assets/css/pp-cards.css';
        $css = is_file($src) ? (string) file_get_contents($src) : '';
        return $css !== '' ? $css : "/* pp-cards.css unavailable */\n";
    }

    /**
     * Extract the presentational component rules from the designer canvas
     * stylesheet (matching an allowlist of component-class prefixes), so the
     * generated app carries the same look without canvas chrome or the app's
     * own grid styles (which live in shell.css).
     */
    private function extractComponentCss(): string
    {
        $src = dirname(__DIR__, 2) . '/modules/Designer/Views/app/designer.css';
        $css = is_file($src) ? (string) file_get_contents($src) : '';
        if ($css === '') {
            return "/* component styles unavailable */\n";
        }
        // strip comments
        $css = (string) preg_replace('!/\*.*?\*/!s', '', $css);

        // NOTE: the allow-list is prefix-matched. Landing-page classes use the
        // `.pp-` prefix (not `.ppd-`), so they must be listed explicitly or the
        // hero silently ships unstyled.
        $allow = ['.pp-hero', '.pp-menu-page', '.pp-mc', '.pp-mg', '.pp-lgrid', '.pp-lgcell', '.pp-ava', '.pp-greet', '.pp-act', '.ppd-gb', '.pp-zone12', '.pp-hero-art', '.pp-hero-clock', '.pp-clk', '.pp-clock-widget', '.ppc-', '.pa-', '.ppd-c-foot',
            '.ppd-c-', '.ppd-stat', '.ppd-widget', '.ppd-badge', '.ppd-toast', '.ppd-progress',
            '.ppd-rating', '.ppd-avatar', '.ppd-timeline', '.ppd-tl-', '.ppd-quicklink', '.ppd-task',
            '.ppd-kpi', '.ppd-code', '.ppd-icon-block', '.ppd-media', '.ppd-map', '.ppd-linkchip',
            '.ppd-totals', '.ppd-total', '.ppd-steps', '.ppd-step', '.ppd-tabbar', '.ppd-tabpane',
            '.ppd-accordion', '.ppd-sep', '.ppd-chip', '.ppd-hr', '.ppd-signature', '.ppd-sig', '.ppd-qr', '.ppd-cal', '.ppd-mcal', '.pp-nepcal', '.pp-nc-', '.pp-ai-', '@keyframes pp-ai-spin', '.pp-cl', '.pp-dl', '.pp-dg', '.pp-fc', '.pp-lc', '.pp-dcl', '.pp-tl', '.pp-pl',
            '.ppd-kanban', '.ppd-modal', '.ppd-offcanvas', '.ppd-feed', '.ppd-minitable', '.ppd-advsearch',
            '.ppd-filterpanel', '.ppd-masterdetail', '.ppd-md-', '.ppd-icm-img',
            '.ppd-filter-chips', '.ppd-fchip', '.pp-fchip', '.ppd-wt-', '.pp-wt-',
            '.ppd-link', '.ppd-c-buttons', '.ppd-btngroup', '.ppd-bg-btn', '.ppd-oc', '.ppd-wf', '.ppd-summary'];
        // '.ppd-tab' matches the chrome left-panel tabs too — allow only the
        // exact component tab selectors.
        $tabExact = ['.ppd-tab ', '.ppd-tab.', '.ppd-tab{', '.ppd-tab,', '.ppd-c-tabs'];

        $matches = function (string $sel) use ($allow, $tabExact): bool {
            foreach ($allow as $a) {
                if (str_contains($sel, $a)) return true;
            }
            foreach ($tabExact as $t) {
                if (str_contains($sel, $t)) return true;
            }
            return false;
        };

        // Split into top-level rules / at-rule blocks via brace-depth scan.
        $out = [];
        $len = strlen($css);
        $buf = '';
        $depth = 0;
        for ($i = 0; $i < $len; $i++) {
            $ch = $css[$i];
            $buf .= $ch;
            if ($ch === '{') {
                $depth++;
            } elseif ($ch === '}') {
                $depth--;
                if ($depth === 0) {
                    $rule = trim($buf);
                    $buf = '';
                    if ($rule === '') continue;
                    $sel = trim(substr($rule, 0, (int) strpos($rule, '{')));
                    if (str_starts_with($sel, '@media')) {
                        // keep matching inner rules only
                        $inner = substr($rule, (int) strpos($rule, '{') + 1);
                        $inner = substr($inner, 0, (int) strrpos($inner, '}'));
                        $kept = [];
                        foreach (preg_split('/(?<=})/', $inner) as $ir) {
                            $ir = trim($ir);
                            if ($ir === '') continue;
                            $isel = trim(substr($ir, 0, (int) strpos($ir, '{') ?: strlen($ir)));
                            if ($matches($isel . '{')) $kept[] = $ir;
                        }
                        if ($kept) {
                            $out[] = $sel . " {\n" . implode("\n", $kept) . "\n}";
                        }
                    } elseif ($matches($sel . '{')) {
                        $out[] = $rule;
                    }
                }
            }
        }

        // @keyframes blocks carry no selector, so the allow-list above drops them
        // and every animation ships dead (the rule keeps its animation-name but
        // the keyframes are gone). Rather than maintain a second name list, keep
        // exactly those referenced by the rules we already decided to ship.
        $kept = implode("\n", $out);
        $names = [];
        if (preg_match_all('/animation(?:-name)?\s*:\s*([^;}]+)/i', $kept, $mm)) {
            foreach ($mm[1] as $decl) {
                foreach (preg_split('/[,\s]+/', trim($decl)) as $tok) {
                    $tok = trim($tok);
                    // skip keywords / times / counts — a keyframe name is an ident
                    if ($tok === '' || preg_match('/^(\d|infinite$|linear$|ease|alternate|both|forwards|backwards|normal$|reverse$|running$|paused$|none$|steps|cubic-bezier|var\()/i', $tok)) continue;
                    $names[$tok] = true;
                }
            }
        }
        $frames = '';
        if ($names !== [] && preg_match_all('/@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/i', $css, $km, PREG_OFFSET_CAPTURE)) {
            foreach ($km[1] as $k => $nameHit) {
                if (!isset($names[$nameHit[0]])) continue;
                // brace-match the block so nested rule sets survive intact
                $start = $km[0][$k][1];
                $depth = 0; $end = $start;
                for ($x = $start, $n = strlen($css); $x < $n; $x++) {
                    if ($css[$x] === '{') $depth++;
                    elseif ($css[$x] === '}') { $depth--; if ($depth === 0) { $end = $x; break; } }
                }
                $frames .= substr($css, $start, $end - $start + 1) . "\n";
            }
        }

        return "/* component styles — mirrors the Designer canvas (ComponentRenderer) */\n"
            . $kept . "\n" . $frames;
    }

    public function generateApplicationPages(string $outputDir, array $tables, string $slug): array
    {
        $this->log('Generating application PHP pages…');
        $written = [];

        // Remember each table's resolved fields so designFor() can synthesise the
        // SAME default template "Reset to Default" produces for a page the user
        // never opened in the Designer (parity — see defaultTemplateFor()).
        $this->tableFields = is_array($tables) ? $tables : [];

        // Project brand (Style → Layout → Logo) so Navbar/Footer components can
        // inherit the same logo/name as the app shell — single source of truth.
        $shLogo = (array) ((($this->ctx['shell'] ?? [])['logo']) ?? []);
        ComponentRenderer::$appBrand = [
            'name'  => trim((string) ($shLogo['text'] ?? '')) ?: (string) ($this->ctx['title'] ?? ''),
            'image' => trim((string) ($shLogo['image'] ?? '')),
        ];

        // Detail grids resolve their child table's Lookup Wizard defs through
        // this provider so embedded rows show display values, not raw FKs.
        ComponentRenderer::$detailLookups = fn (string $t): array => $this->lookupFieldsFor($t);
        ComponentRenderer::$detailMeta = function (string $t): array {
            $fn = $this->functionalityFor($t);
            return [
                'inlineEdit'  => !empty($fn['inlineEdit']) || !empty($fn['spreadsheet']),
                'inlineAdd'   => !empty($fn['inlineAdd']),
                'spreadsheet' => !empty($fn['spreadsheet']),
            ];
        };

        $written = array_merge($written, $this->generateRuntimeBootstrap($outputDir, $slug));
        $written = array_merge($written, $this->generateLang($outputDir));
        $written = array_merge($written, $this->generateLiveTranslations($outputDir));
        $written = array_merge($written, $this->generateShell($outputDir, $tables));
        $written = array_merge($written, $this->generateAuthPages($outputDir));
        $written = array_merge($written, $this->generateDashboardPage($outputDir, $tables));
        $written = array_merge($written, $this->generateCalendarApp($outputDir));

        foreach ($tables as $tableName => $fields) {
            // Only generate the page types enabled in the Pages module
            $types = $this->pageTypesFor($tableName);
            if (in_array('list', $types, true)) {
                $written = array_merge($written, $this->generateTableListPage($outputDir, $tableName, $fields));
            }
            if (in_array('add', $types, true)) {
                $written = array_merge($written, $this->generateTableFormPage($outputDir, $tableName, $fields, 'add'));
            }
            if (in_array('edit', $types, true)) {
                $written = array_merge($written, $this->generateTableFormPage($outputDir, $tableName, $fields, 'edit'));
            }
            if (in_array('view', $types, true)) {
                $written = array_merge($written, $this->generateTableViewPage($outputDir, $tableName, $fields));
            }
            // (The Calendar is no longer a per-table page-type — it is a
            // standalone event-management app generated once, see generateCalendarApp().
            // Clean up any calendar.php left by the earlier table-overlay cut.)
            @unlink($outputDir . '/generated/' . $tableName . '/calendar.php');
            // Import is gated by the page type AND by Import Settings — clearing
            // every file type means "no import", so no endpoint is shipped.
            if (in_array('import', $types, true) && $this->allowedImportFormats($tableName) !== []) {
                $written = array_merge($written, $this->generateTableImportPage($outputDir, $tableName, $fields));
            } else {
                @unlink($outputDir . '/generated/' . $tableName . '/import.php');
                if (in_array('import', $types, true)) {
                    $this->log("Import disabled for $tableName — import.php not generated");
                }
            }
            // User-added custom (blank) pages — one file per `custom_<slug>`,
            // rendering whatever components the user placed on the responsive grid.
            $written = array_merge($written, $this->generateCustomPages($outputDir, $tableName));

            $skipped = array_diff(['list', 'add', 'edit', 'view'], $types);
            foreach ($skipped as $skip) {
                // Remove placeholder artifacts the manifest may have written
                @unlink($outputDir . '/generated/' . $tableName . '/' . $skip . '.php');
            }
            if ($skipped !== []) {
                $this->log("Skipped disabled page(s) for $tableName: " . implode(', ', $skipped));
            }
        }

        // Standalone (table-less) custom pages → generated/__standalone__/<pt>.php.
        $written = array_merge($written, $this->generateCustomPages($outputDir, '__standalone__'));
        // SEO files for standalone sites (no DB sitemap): a dynamic sitemap.php that
        // lists the indexable landing pages, plus robots.txt.
        $written = array_merge($written, $this->generateStandaloneSeoFiles($outputDir, $slug));

        $written = array_merge($written, $this->generateApiEndpoints($outputDir, $tables, $slug));
        // Chrome for the Admin Area comes from its Designer common page (if the
        // user opened & customised it); otherwise the defaults below apply.
        $adminDesign = $this->commonDesignFor('admin_area');
        $adminOpts = $adminDesign ? ($this->componentProps($adminDesign)['admin-area'] ?? []) : [];
        $written = array_merge($written, $this->generateAdminArea($outputDir, $tables, $adminOpts));
        // Audit Log / Record Locks in-app pages (Dynamic mode). Always emitted; they
        // self-gate on admin + PP_AUDIT_MODE/PP_LOCK_MODE === 'dynamic' at runtime,
        // and are only linked from the user menu when dynamic (see pageChrome).
        $written = array_merge($written, $this->generateAuditLockPages($outputDir));
        $written = array_merge($written, $this->generateAppJs($outputDir));
        $written = array_merge($written, $this->generatePpJs($outputDir));

        // Now that every page/component has rendered, fold the Designer component
        // content strings collected during this build into the Translations-editor
        // catalogue (live mode only) so they are discoverable + translatable in-app.
        $this->augmentContentCatalogue($outputDir);

        return $written;
    }

    /**
     * Append a `content` group (Designer component text routed through pp_ct) to the
     * in-app Translations catalogue, using the strings ComponentRenderer collected
     * while rendering this build. No-op unless live translation editing is enabled
     * (the catalogue only ships then). The reference text shown to a translator is
     * the default-language value where one exists, else the authored string itself.
     */
    private function augmentContentCatalogue(string $outputDir): void
    {
        if (($this->ctx['miscProject']['liveTranslations'] ?? false) !== true) return;
        $file = $outputDir . '/app/i18n-catalogue.php';
        if (!is_file($file)) return;
        $catalogue = @include $file;
        if (!is_array($catalogue)) return;

        $default = (string) (($this->ctx['languages'][0]['code'] ?? null) ?: 'en');
        $defContent = (array) ($this->ctx['labels'][$default]['content'] ?? []);
        $group = [];
        foreach (\PpPhp\Core\Build\ComponentRenderer::takeContent() as $src) {
            if ($src === '') continue;
            $group[$src] = (string) ($defContent[$src] ?? $src);
        }
        // Keep any strings the author translated but that were not rendered this pass.
        foreach ($defContent as $src => $val) {
            if ($src !== '' && !isset($group[$src])) $group[$src] = (string) ($val ?: $src);
        }
        if ($group === []) return;
        ksort($group);
        $catalogue['content'] = $group;
        $this->writeFile($file,
            "<?php\n\ndeclare(strict_types=1);\n\n"
            . "// Editable-string catalogue for the in-app Translations editor (Phase 2).\n"
            . "// Auto-generated from the Misc labels + designer. Keys map to dict keys via\n"
            . "// section prefixes: ui.<k>, menu.<id>, table.<t>.caption, label.<t>.<f>,\n"
            . "// page.<t>.<type>, content.<sourceText>.\n"
            . "return " . var_export($catalogue, true) . ";\n");
    }

    /**
     * Copy the project's Custom Files tree (projects/<user>/<slug>/custom) into
     * the generated app root, preserving folder structure so the files are
     * served as part of the site. Reserved system paths are skipped so a stray
     * upload can never overwrite the generated runtime.
     *
     * @return array<int, string> paths written
     */
    public function copyCustomFiles(string $outputDir, string $slug): array
    {
        $written  = [];
        // Resolve through ProjectLocator, never the session: a build started from
        // the CLI (cron, deploy script) has no session at all and silently used
        // "default", and an admin building someone else's project used their OWN
        // folder — both dropped every custom file from the output without a word.
        $base     = \PpPhp\Core\ProjectLocator::dir($slug) . '/custom';
        if (!is_dir($base)) {
            return $written;
        }
        $reserved = [
            '_runtime.php', 'index.php', 'app-shell.php', 'cron.php', 'routes.json',
            'manifest.json', 'database.sql', 'core', 'app', 'api', 'config', 'generated', 'plugins',
        ];
        $copied = 0; $skipped = [];
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($it as $item) {
            $rel = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($base))), '/');
            if ($rel === '') continue;
            $top = strtolower(explode('/', $rel)[0]);
            if (in_array($top, $reserved, true)) { $skipped[$top] = true; continue; }
            $dest = $outputDir . '/' . $rel;
            if ($item->isDir()) {
                if (!is_dir($dest)) @mkdir($dest, 0775, true);
            } else {
                if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0775, true);
                if (@copy($item->getPathname(), $dest)) { $written[] = $dest; $this->filesWritten[] = $dest; $copied++; }
            }
        }
        if ($copied > 0 || $skipped !== []) {
            $this->log("Custom Files: copied {$copied} file(s)"
                . ($skipped !== [] ? '; skipped reserved (' . implode(', ', array_keys($skipped)) . ')' : ''));
        }
        return $written;
    }

    /**
     * The project's field-encryption key (base64 of 32 random bytes). Generated
     * once and persisted at projects/<owner>/<slug>/config/encryption.key so it is
     * identical on every rebuild — regenerating it would orphan already-encrypted
     * data. Lives on the server / in the built app config, never in the database.
     */
    private function projectEncryptionKey(string $slug): string
    {
        try {
            return \PpPhp\Core\Crypto\ProjectKey::get(\PpPhp\Core\ProjectLocator::ownerUsername($slug), $slug);
        } catch (\Throwable $e) {
            // Fall back to a build-local key rather than failing the build. Encryption
            // still works within this build; only cross-rebuild stability is at risk.
            return base64_encode(random_bytes(32));
        }
    }

    private function generateRuntimeBootstrap(string $outputDir, string $slug): array
    {
        $this->log('Generating _runtime.php…');
        $path = $outputDir . '/_runtime.php';
        $content = <<<'RUNTIME'
<?php
declare(strict_types=1);

if (!defined('BASE_PATH')) define('BASE_PATH', __DIR__);

// ── URL base auto-detection ─────────────────────────────────────
// Derives the public URL prefix of the app root from the executing
// script, so the same bundle works at the web root, in any
// subdirectory, or under `php -S` — no configuration needed.
if (!defined('APP_BASE')) {
    if (PHP_SAPI === 'cli') {
        define('APP_BASE', '');
    } else {
        $__sf  = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_FILENAME'] ?? ''));
        $__sn  = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
        $__rt  = str_replace('\\', '/', BASE_PATH);
        $__rel = str_starts_with($__sf, $__rt) ? substr($__sf, strlen($__rt)) : '';
        if ($__rel !== '' && str_ends_with($__sn, $__rel)) {
            $__base = substr($__sn, 0, strlen($__sn) - strlen($__rel));
        } else {
            $__base = str_replace('\\', '/', dirname($__sn));
        }
        define('APP_BASE', rtrim($__base === '.' ? '' : $__base, '/'));
        unset($__sf, $__sn, $__rt, $__rel, $__base);
    }
}
if (!defined('BASE_URL')) define('BASE_URL', APP_BASE);

// ── Autoloader ──
require_once BASE_PATH . '/core/Autoloader.php';
$loader = new \PpPhp\Core\Autoloader();
$loader->addNamespace('PpPhp\\Core\\', BASE_PATH . '/core/');
$loader->register();

// ── Helpers ──
if (is_file(BASE_PATH . '/core/Helpers.php')) {
    require_once BASE_PATH . '/core/Helpers.php';
}

// ── App config ──
$appConfig = is_file(BASE_PATH . '/config/app.php') ? require BASE_PATH . '/config/app.php' : [];

// ── Session ──
// `secure` is set only when the request actually arrived over HTTPS — forcing
// it on plain HTTP would make the cookie unsettable and break sign-in entirely.
$__https = (($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off')
    || ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443)
    || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
session_set_cookie_params([
    'lifetime' => 0, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => $__https,
]);
// Optional custom session cookie name (Security → Session Control). Must be set
// before session_start(); the build guarantees a PHP-legal, sanitized value.
if (defined('PP_SESSION_NAME') && PP_SESSION_NAME !== '' && session_status() === PHP_SESSION_NONE) {
    session_name(PP_SESSION_NAME);
}
if (session_status() === PHP_SESSION_NONE) session_start();

// ── Fresh install? ── If the database isn't configured yet but a standalone
// installer is bundled, route the visitor to it rather than fataling on the
// missing config. Harmless for normal builds (config/database.php is present).
if (!is_file(BASE_PATH . '/config/database.php')) {
    if (is_file(BASE_PATH . '/config/standalone.php')) {
        // Standalone app: no database by design. It is public (no auth) and runs
        // from its Designer pages only — skip the DB connection entirely.
        if (!defined('PP_STANDALONE')) define('PP_STANDALONE', true);
        if (!defined('PP_AUTH_ENABLED')) define('PP_AUTH_ENABLED', false);
    } elseif (PHP_SAPI !== 'cli' && is_file(BASE_PATH . '/install.php')) {
        header('Location: ' . APP_BASE . '/install.php');
        exit;
    } else {
        http_response_code(500);
        exit('This app is not configured yet: config/database.php is missing.');
    }
}

// ── Database connection (skipped for standalone apps) ──
if (!defined('PP_STANDALONE')) {
    $dbConfig = \PpPhp\Core\DatabaseConnection::normalize(require BASE_PATH . '/config/database.php');
    $dbDriver = \PpPhp\Core\DatabaseConnection::preferredDriver($dbConfig['type']);
    $dsn = \PpPhp\Core\DatabaseConnection::buildDsn($dbConfig, BASE_PATH, $dbDriver);
    \PpPhp\Core\DB::AddConnection(
        'default',
        $dsn,
        $dbConfig['type'] === 'sqlite' ? null : $dbConfig['user'],
        $dbConfig['type'] === 'sqlite' ? null : $dbConfig['pass'],
        \PpPhp\Core\DatabaseConnection::pdoOptions($dbConfig, $dbDriver)
    );
}

// ── Session control (Security → Session Control) ──
// Idle timeout, absolute lifetime and admin "terminate" are enforced on every
// authenticated request; anonymous pages (login, public) are a no-op. Runs after
// the DB is connected because it reads/writes the user_sessions table.
if (defined('PP_AUTH_ENABLED') && PP_AUTH_ENABLED && class_exists('\PpPhp\Core\Auth\SessionGuard')) {
    \PpPhp\Core\Auth\SessionGuard::enforce();
}

// ── Multi-language runtime (Misc module) ──
if (is_file(BASE_PATH . '/app/lang.php')) {
    require_once BASE_PATH . '/app/lang.php';
    if (isset($_GET['pp_lang'])) {
        pp_set_lang((string) $_GET['pp_lang']);
        // Redirect to the same URL without the pp_lang parameter
        $__q = $_GET;
        unset($__q['pp_lang']);
        $__uri = strtok((string) ($_SERVER['REQUEST_URI'] ?? '/'), '?');
        header('Location: ' . $__uri . ($__q ? '?' . http_build_query($__q) : ''));
        exit;
    }
}

// ── Application shell (Style designer: navbar + menu) ──
if (is_file(BASE_PATH . '/app/shell.php')) {
    require_once BASE_PATH . '/app/shell.php';
}

// ── Messaging (Misc module Email / SMS) ──
if (is_file(BASE_PATH . '/config/mail.php') && class_exists('\PpPhp\Core\Email')) {
    \PpPhp\Core\Email::setSettings(require BASE_PATH . '/config/mail.php');
}
if (is_file(BASE_PATH . '/config/sms.php') && class_exists('\PpPhp\Core\SMS')) {
    \PpPhp\Core\SMS::init();
    \PpPhp\Core\SMS::SetConfig(require BASE_PATH . '/config/sms.php');
}

/**
 * Send an email through the configured mailer, firing the Events designer
 * hooks (beforeSendEmail can modify/cancel; afterSendEmail / onEmailFailed run after).
 */
function pp_send_email(string $to, string $subject, string $body, array $data = []): array
{
    if (!is_file(BASE_PATH . '/config/mail.php')) {
        return ['success' => false, 'message' => 'Email is not configured.'];
    }
    $hook = \PpPhp\Core\Events::fire('global.email.beforeSendEmail', [
        'to' => $to, 'subject' => &$subject, 'body' => &$body, 'data' => $data,
    ]);
    if ($hook === false) {
        return ['success' => false, 'message' => 'Cancelled by beforeSendEmail hook.'];
    }
    try {
        $result = \PpPhp\Core\Email::send(['to' => $to, 'subject' => $subject, 'body' => $body] + $data);
        $ok = is_array($result) ? (bool) ($result['success'] ?? true) : (bool) $result;
        if ($ok) {
            \PpPhp\Core\Events::fire('global.email.afterSendEmail', ['to' => $to, 'subject' => $subject, 'result' => (array) $result]);
        } else {
            \PpPhp\Core\Events::fire('global.email.onEmailFailed', ['to' => $to, 'subject' => $subject, 'error' => (string) (is_array($result) ? ($result['message'] ?? 'send failed') : 'send failed')]);
        }
        return is_array($result) ? $result : ['success' => $ok];
    } catch (\Throwable $e) {
        \PpPhp\Core\Events::fire('global.email.onEmailFailed', ['to' => $to, 'subject' => $subject, 'error' => $e->getMessage()]);
        return ['success' => false, 'message' => $e->getMessage()];
    }
}

/** Send an SMS through the configured provider (Misc module → SMS). */
function pp_send_sms(string $to, string $message): array
{
    if (!is_file(BASE_PATH . '/config/sms.php')) {
        return ['success' => false, 'message' => 'SMS is not configured.'];
    }
    try {
        return \PpPhp\Core\SMS::Send($to, $message);
    } catch (\Throwable $e) {
        return ['success' => false, 'message' => $e->getMessage()];
    }
}

// ── Custom functions (Events designer) ──
if (is_file(BASE_PATH . '/app/functions.php')) {
    require_once BASE_PATH . '/app/functions.php';
}

// ── Application events ──
\PpPhp\Core\Events::fire('global.app.appInit', ['app' => (object) $appConfig]);
$__hook = \PpPhp\Core\Events::fire('global.app.beforeProcess', [
    'page'    => ['uri' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH)],
    'request' => $_REQUEST,
    'user'    => $_SESSION['user'] ?? [],
]);
if (is_string($__hook) && $__hook !== '') { header('Location: ' . $__hook); exit; }
if ($__hook === false) { http_response_code(403); exit('Forbidden'); }
unset($__hook);

// ── API & Webhook events ─────────────────────────────────────────
// For any request under /api/, fire beforeApiRequest (return false → 403),
// then buffer the JSON response so beforeApiResponse can rewrite it and
// afterApiRequest can observe it — without touching the individual endpoints.
if (str_contains((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/api/')) {
    $GLOBALS['__PP_API_ENDPOINT'] = basename((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
    $__apiReq = \PpPhp\Core\Events::fire('global.api.beforeApiRequest', [
        'endpoint' => $GLOBALS['__PP_API_ENDPOINT'],
        'method'   => $_SERVER['REQUEST_METHOD'] ?? 'GET',
        'request'  => $_REQUEST,
    ]);
    if ($__apiReq === false) {
        http_response_code(403);
        header('Content-Type: application/json');
        echo json_encode(['success' => false, 'error' => 'Forbidden']);
        exit;
    }
    unset($__apiReq);
    ob_start(static function (string $__buf): string {
        try {
            $__ep = (string) ($GLOBALS['__PP_API_ENDPOINT'] ?? '');
            $__status = http_response_code() ?: 200;
            $__decoded = json_decode($__buf, true);
            if (is_array($__decoded)) {
                $__body = $__decoded;
                \PpPhp\Core\Events::fire('global.api.beforeApiResponse', ['body' => &$__body, 'status' => &$__status, 'endpoint' => $__ep]);
                \PpPhp\Core\Events::fire('global.api.afterApiRequest', ['endpoint' => $__ep, 'response' => $__body]);
                if (is_int($__status) && $__status >= 100) { http_response_code($__status); }
                $__out = json_encode($__body);
                return $__out !== false ? $__out : $__buf;
            }
            \PpPhp\Core\Events::fire('global.api.afterApiRequest', ['endpoint' => $__ep, 'response' => $__buf]);
        } catch (\Throwable $__e) {
            error_log('[events] api response hook failed: ' . $__e->getMessage());
        }
        return $__buf;
    });
}

// ── Global helper functions ──

function db(): \PpPhp\Core\DB { return new \PpPhp\Core\DB(); }
function auth(): \PpPhp\Core\Security { return new \PpPhp\Core\Security(); }

// ── PHP ↔ JavaScript proxy values ───────────────────────────────
// Bridge a value computed in a server (PHP) event to a client (JS)
// event. Call pp_set_proxy() in a PHP event; read it in a JS event via
// pp.proxy.<key> or pp.getProxy('<key>'). Values are JSON-serialised
// into the page right before events.js runs.
function pp_set_proxy(string $key, mixed $value): void
{
    if (!isset($GLOBALS['__PP_PROXY']) || !is_array($GLOBALS['__PP_PROXY'])) $GLOBALS['__PP_PROXY'] = [];
    $GLOBALS['__PP_PROXY'][$key] = $value;
}
function pp_get_proxy(string $key, mixed $default = null): mixed
{
    return $GLOBALS['__PP_PROXY'][$key] ?? $default;
}
function pp_proxy_all(): array
{
    return isset($GLOBALS['__PP_PROXY']) && is_array($GLOBALS['__PP_PROXY']) ? $GLOBALS['__PP_PROXY'] : [];
}

// ── pp() — the server-side companion to pp.js (window.Runner) ────
// One per-request facade for context (pp()->user/role/can/page/param),
// permission-aware data (find/insert/update/delete), and a command bus that
// drives the browser live (pp()->toast/setControl/hide/reloadGrid…). The
// queued commands are returned by api/event.php + api/button.php and applied
// by pp.js's Runner.apply(). See the "Server API" Help for the full list.
function pp(): \PpPhp\Core\PpApi
{
    return \PpPhp\Core\PpApi::instance();
}
// Emit the server→client bridge (proxy values + app base + optional record).
// Call right before the events.js include so all server events have run.
function pp_proxy_script(mixed $record = null): string
{
    $payload = ['proxy' => pp_proxy_all(), 'base' => defined('APP_BASE') ? APP_BASE : ''];
    if ($record !== null) $payload['record'] = $record;
    return '<script>window.__PP__=' . json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) . ';</script>';
}

// ── File & Storage service ───────────────────────────────────────
// A small, secure upload store confined to <app>/uploads. Each helper
// fires the matching Events (File & Storage group). Call them from a
// server action or event, e.g. pp_store_upload($_FILES['file']).
function pp_upload_dir(): string
{
    $dir = (defined('BASE_PATH') ? BASE_PATH : __DIR__) . '/uploads';
    if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
    $ht = $dir . '/.htaccess';
    if (!is_file($ht)) {
        @file_put_contents($ht, "php_flag engine off\nOptions -ExecCGI -Indexes\n<FilesMatch \"\\.(php|phtml|phar|php[0-9]|pht|cgi|pl|sh)\$\">\n  Require all denied\n</FilesMatch>\n");
    }
    return $dir;
}

function pp_upload_url(string $absPath): string
{
    $base = pp_upload_dir();
    $rel = ltrim(str_replace(['\\', $base], ['/', ''], $absPath), '/');
    return (defined('APP_BASE') ? APP_BASE : '') . '/uploads/' . $rel;
}

/** Resolve a path/url to an absolute file INSIDE the uploads dir, or null. */
function pp_resolve_upload(string $path): ?string
{
    $base = realpath(pp_upload_dir());
    if ($base === false) return null;
    $candidate = str_replace('\\', '/', $path);
    if (str_contains($candidate, '/uploads/')) {
        $candidate = substr($candidate, strpos($candidate, '/uploads/') + 9);
    }
    $candidate = ltrim($candidate, '/');
    $abs = realpath($base . '/' . $candidate);
    if ($abs === false) $abs = realpath($path);
    if ($abs === false) return null;
    if ($abs !== $base && !str_starts_with($abs, $base . DIRECTORY_SEPARATOR)) return null;
    return is_file($abs) ? $abs : null;
}

/**
 * Store an uploaded file. $file is a $_FILES entry (name, tmp_name, size, …).
 * Fires global.file.beforeFileUpload (return false to cancel) then afterFileUpload.
 * @return array{success:bool,path?:string,url?:string,name?:string,size?:int,ext?:string,error?:string}
 */
function pp_store_upload(array $file, array $opts = []): array
{
    $name = (string) ($file['name'] ?? '');
    $tmp  = (string) ($file['tmp_name'] ?? '');
    $size = (int) ($file['size'] ?? 0);
    $err  = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
    if ($err !== UPLOAD_ERR_OK || $tmp === '') {
        return ['success' => false, 'error' => 'No file was uploaded (code ' . $err . ').'];
    }
    $ext   = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
    $deny  = ['php','phtml','phar','php3','php4','php5','pht','cgi','pl','sh','exe','htaccess'];
    $allow = array_map('strtolower', (array) ($opts['allow'] ?? ['jpg','jpeg','png','gif','webp','svg','pdf','doc','docx','xls','xlsx','csv','txt','zip']));
    $maxMb = (float) ($opts['maxMb'] ?? 10);
    if ($ext === '' || in_array($ext, $deny, true) || ($allow && !in_array($ext, $allow, true))) {
        return ['success' => false, 'error' => 'File type .' . $ext . ' is not allowed.'];
    }
    if ($size > $maxMb * 1024 * 1024) {
        return ['success' => false, 'error' => 'File exceeds the ' . $maxMb . ' MB limit.'];
    }
    $meta = ['name' => $name, 'ext' => $ext, 'size' => $size, 'type' => (string) ($file['type'] ?? '')];
    if (\PpPhp\Core\Events::fire('global.file.beforeFileUpload', ['file' => &$meta]) === false) {
        return ['success' => false, 'error' => 'Upload cancelled.'];
    }
    $destDir = pp_upload_dir() . '/' . date('Y') . '/' . date('m');
    if (!is_dir($destDir)) { @mkdir($destDir, 0775, true); }
    $safeExt = '.' . preg_replace('/[^a-z0-9]/', '', $ext);
    $abs = $destDir . '/' . bin2hex(random_bytes(8)) . $safeExt;
    if (!@move_uploaded_file($tmp, $abs) && !@rename($tmp, $abs) && !@copy($tmp, $abs)) {
        return ['success' => false, 'error' => 'Could not store the uploaded file.'];
    }
    $result = ['success' => true, 'path' => $abs, 'url' => pp_upload_url($abs),
               'name' => (string) ($meta['name'] ?? $name), 'size' => $size, 'ext' => $ext];
    \PpPhp\Core\Events::fire('global.file.afterFileUpload', ['file' => $result]);
    return $result;
}

/**
 * Stream a stored file to the browser. Fires global.file.beforeFileDownload
 * (return false to deny). $path may be an absolute path or an /uploads/ URL.
 */
function pp_send_file(string $path, array $opts = []): void
{
    $abs  = pp_resolve_upload($path);
    $meta = ['path' => $abs, 'requested' => $path, 'name' => $abs ? basename($abs) : basename($path)];
    if (\PpPhp\Core\Events::fire('global.file.beforeFileDownload', ['file' => &$meta]) === false) {
        http_response_code(403); echo 'Forbidden'; return;
    }
    $abs = (string) ($meta['path'] ?? $abs);
    if ($abs === '' || !is_file($abs)) { http_response_code(404); echo 'Not found'; return; }
    while (function_exists('ob_get_level') && ob_get_level() > 0) { @ob_end_clean(); }
    $disp  = !empty($opts['inline']) ? 'inline' : 'attachment';
    $dname = str_replace('"', '', (string) ($opts['name'] ?? ($meta['name'] ?? basename($abs))));
    $mime  = function_exists('mime_content_type') ? (mime_content_type($abs) ?: 'application/octet-stream') : 'application/octet-stream';
    header('Content-Type: ' . $mime);
    header('Content-Disposition: ' . $disp . '; filename="' . $dname . '"');
    header('Content-Length: ' . (string) filesize($abs));
    readfile($abs);
}

/** Delete a stored file. Fires global.file.beforeFileDelete (return false to keep). */
function pp_delete_file(string $path): bool
{
    $abs = pp_resolve_upload($path);
    if ($abs === null) return false;
    $meta = ['path' => $abs, 'name' => basename($abs)];
    if (\PpPhp\Core\Events::fire('global.file.beforeFileDelete', ['file' => &$meta]) === false) return false;
    return @unlink((string) ($meta['path'] ?? $abs));
}

/**
 * Storage GC for File/Image columns (StorageRef aware, driver agnostic).
 * pp_upload_prune deletes the backing objects present in $old but no longer in
 * $new (a file removed/replaced on edit); pp_upload_purge removes them all
 * (a deleted record). Legacy bare-path values are left untouched. Each deletion
 * honours global.file.beforeFileDelete (return false to keep the object).
 */
function pp_upload_prune($old, $new = ''): void
{
    if (!class_exists('\PpPhp\Core\Storage\StorageRef')) return;
    $oldRefs = \PpPhp\Core\Storage\StorageRef::decode($old);
    if ($oldRefs === []) return;
    $keep = [];
    foreach (\PpPhp\Core\Storage\StorageRef::decode($new) as $r) {
        $keep[($r['driver'] ?? 'local') . '|' . ($r['key'] ?? '')] = true;
    }
    foreach ($oldRefs as $r) {
        if (isset($keep[($r['driver'] ?? 'local') . '|' . ($r['key'] ?? '')])) continue;
        // Only delete real uploaded objects, never a bare external URL row.
        if (($r['key'] ?? '') === '' || preg_match('#^(https?:)?//|^data:#i', (string) $r['key'])) continue;
        $meta = ['ref' => $r, 'name' => (string) ($r['name'] ?? '')];
        if (\PpPhp\Core\Events::fire('global.file.beforeFileDelete', ['file' => &$meta]) === false) continue;
        try { \PpPhp\Core\Storage\StorageRegistry::forRef($r)->delete($r); } catch (\Throwable $e) {}
    }
}
function pp_upload_purge($val): void { pp_upload_prune($val, ''); }

/**
 * File/Image field helpers for events & custom code. A File/Image column holds a
 * StorageRef (JSON), not a plain path — these resolve it to what you actually
 * want without knowing the storage backend (self-hosted, S3, Drive, OneDrive):
 *
 *   pp_file_refs($row['pic'])  → the decoded attachment list (name/size/mime/…)
 *   pp_file_url($row['pic'])   → the first file's ready-to-use URL
 *   pp_file_urls($row['pic'])  → every file's URL (multi-file fields)
 *
 * Local files live at Files/public/<folder>/<file> (private ones stream through
 * api/file.php); the URL is public, signed, or app-proxied automatically.
 */
function pp_file_refs($value): array
{
    return class_exists('\PpPhp\Core\Storage\StorageRef') ? \PpPhp\Core\Storage\StorageRef::decode($value) : [];
}
function pp_file_urls($value, array $opts = []): array
{
    $out = [];
    foreach (pp_file_refs($value) as $ref) {
        try { $u = \PpPhp\Core\Storage\StorageRegistry::urlFor($ref, $opts); if ($u !== '') $out[] = $u; }
        catch (\Throwable $e) {}
    }
    return $out;
}
function pp_file_url($value, array $opts = []): string
{
    $urls = pp_file_urls($value, $opts);
    return $urls[0] ?? '';
}

/**
 * Push a live update to every browser subscribed to a channel (real-time).
 * Fires the global.realtime.beforeBroadcast hook first (which may edit the
 * payload by reference or return false to cancel), then writes to the bus that
 * api/realtime.php streams. Best-effort — never breaks the action that fired it.
 */
function pp_broadcast(string $channel, string $event, mixed $payload = null, ?int $userId = null): int
{
    if (class_exists('\\PpPhp\\Core\\Events')) {
        $r = \PpPhp\Core\Events::fire('global.realtime.beforeBroadcast', ['channel' => $channel, 'event' => $event, 'payload' => &$payload]);
        if ($r === false) return 0;
    }
    if (!class_exists('\\PpPhp\\Core\\Realtime\\RealtimeBus')) return 0;
    return \PpPhp\Core\Realtime\RealtimeBus::publish($channel, $event, $payload, $userId);
}

/** Live update to ONE user across all their open tabs (private "user.<id>" channel). */
function pp_broadcast_user(int $userId, string $event, mixed $payload = null): int
{
    return pp_broadcast('user.' . $userId, $event, $payload, $userId);
}

/**
 * The primary-key column for a table, from the generated app/pk.php registry
 * (built from the live schema). Not every table uses `id` — an imported table
 * might key on `std_id`, `code`, etc. Falls back to `id` when unknown.
 */
function pp_pk(string $table): string
{
    static $map = null;
    if ($map === null) {
        $f = __DIR__ . '/app/pk.php';
        $map = is_file($f) ? (array) (require $f) : [];
    }
    $t = preg_replace('/[^A-Za-z0-9_]/', '', $table);
    return (isset($map[$t]) && $map[$t] !== '') ? (string) $map[$t] : 'id';
}

/**
 * Premium-service entitlements for the CURRENT user. A service (AI / Email / SMS)
 * is usable only when it is CONFIGURED (an admin added its key/creds in Misc) AND
 * the user is entitled — no premium group set means everyone signed in; otherwise
 * only members of a configured premium group. Read by the client as
 * window.PP_ENTITLE to gate the AI / Email / SMS buttons ("Get Premium" otherwise).
 *
 * @return array{ai:bool, email:bool, sms:bool}
 */
function pp_entitlements(): array
{
    $ai    = class_exists('\PpPhp\Core\Ai\AiClient') && \PpPhp\Core\Ai\AiClient::isConfigured();
    $base  = defined('BASE_PATH') ? (string) \constant('BASE_PATH') : __DIR__;
    // "Configured" means actually usable — a host with no credentials is NOT.
    $email = false;
    foreach (['/config/mail.php', '/config/email.php'] as $__f) {
        if (is_file($base . $__f)) {
            $__c = @include $base . $__f;
            if (is_array($__c) && trim((string) ($__c['host'] ?? '')) !== '' && trim((string) ($__c['username'] ?? $__c['user'] ?? '')) !== '' && trim((string) ($__c['password'] ?? $__c['pass'] ?? '')) !== '') $email = true;
            break;
        }
    }
    $sms = false;
    if (is_file($base . '/config/sms.php')) {
        $__c = @include $base . '/config/sms.php';
        if (is_array($__c) && trim((string) ($__c['provider'] ?? '')) !== '' && trim((string) ($__c['api_token'] ?? $__c['api_key'] ?? $__c['token'] ?? $__c['password'] ?? '')) !== '') $sms = true;
    }
    // Premium group gate. Empty = available to everyone signed in.
    $groups = array_values(array_filter(array_map('strval', (array) ($GLOBALS['PP_PREMIUM_GROUPS'] ?? []))));
    $entitled = true;
    if ($groups !== []) {
        $u = $_SESSION['user'] ?? [];
        $mine = array_filter([(string) ($u['role'] ?? ''), (string) ($u['group'] ?? ''), (string) ($u['group_name'] ?? '')]);
        $entitled = $mine !== [] && array_intersect($groups, $mine) !== [];
    }
    return ['ai' => $ai && $entitled, 'email' => $email && $entitled, 'sms' => $sms && $entitled];
}

/**
 * The signing secret for public Client-Portal links, from config/portal.php.
 * Portal-link tokens are HMAC(table|id, secret); portal.php verifies them.
 */
function pp_portal_secret(): string
{
    static $s = null;
    if ($s === null) {
        $f = (defined('BASE_PATH') ? (string) \constant('BASE_PATH') : __DIR__) . '/config/portal.php';
        $cfg = is_file($f) ? (array) @include $f : [];
        $s = (string) ($cfg['secret'] ?? '');
    }
    return $s;
}

/**
 * Ensure every row exposes an `id` key holding the primary-key value, so the
 * generated pages' `$row['id']` / `?id=` links work whatever the real PK is.
 */
function pp_with_id(array &$rows, string $table): void
{
    $pk = pp_pk($table);
    if ($pk === 'id') return;
    foreach ($rows as &$r) {
        if (is_array($r) && !array_key_exists('id', $r)) $r['id'] = $r[$pk] ?? null;
    }
    unset($r);
}

/**
 * Replace lookup (foreign-key) values with their display values, IN PLACE.
 * Used by the print and export pages so documents show names, not keys.
 * $lookups: [field => ['table' => ..., 'key' => ..., 'display' => ...]]
 * One batched IN() query per lookup field; unknown keys stay raw.
 */
function pp_apply_lookups(array &$rows, array $lookups): void
{
    foreach ($lookups as $field => $lk) {
        if (($lk['displayAs'] ?? 'label') === 'value') continue;   // show the raw key
        $t = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['table'] ?? ''));
        $k = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['key'] ?? 'id')) ?: 'id';
        $d = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['display'] ?? ''));
        if ($t === '' || $d === '') continue;
        $keys = [];
        foreach ($rows as $r) {
            foreach (explode(',', (string) ($r[$field] ?? '')) as $part) {   // CSV-aware (multi lookups)
                $part = trim($part);
                if ($part !== '') $keys[$part] = true;
            }
        }
        if ($keys === []) continue;
        $in = implode(',', array_fill(0, count($keys), '?'));
        try {
            $map = [];
            foreach (\PpPhp\Core\DB::FetchAll("SELECT `$k` AS k, `$d` AS d FROM `$t` WHERE `$k` IN ($in)", array_keys($keys)) ?: [] as $r) {
                $map[(string) $r['k']] = (string) $r['d'];
            }
            foreach ($rows as &$r) {
                $v = (string) ($r[$field] ?? '');
                if ($v === '') continue;
                if (isset($map[$v])) { $r[$field] = $map[$v]; continue; }
                if (strpos($v, ',') !== false) {
                    $parts = [];
                    foreach (explode(',', $v) as $p) { $p = trim($p); if ($p !== '') $parts[] = $map[$p] ?? $p; }
                    $r[$field] = implode(', ', $parts);
                }
            }
            unset($r);
        } catch (\Throwable $e) { /* keep raw keys */ }
    }
}

/**
 * Display value(s) for one lookup field value — single key or CSV of keys
 * (multi-value lookups). Unknown keys stay raw. Used by the View page.
 */
function pp_lookup_display_value(array $lk, string $value): string
{
    if ($value === '') return '';
    $t = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['table'] ?? ''));
    $k = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['key'] ?? 'id')) ?: 'id';
    $d = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['display'] ?? ''));
    if ($t === '' || $d === '') return $value;
    $keys = array_values(array_filter(array_map('trim', explode(',', $value)), 'strlen'));
    if ($keys === []) return $value;
    try {
        $in = implode(',', array_fill(0, count($keys), '?'));
        $map = [];
        foreach (\PpPhp\Core\DB::FetchAll("SELECT `$k` AS k, `$d` AS d FROM `$t` WHERE `$k` IN ($in)", $keys) ?: [] as $r) {
            $map[(string) $r['k']] = (string) $r['d'];
        }
        $out = [];
        foreach ($keys as $key) $out[] = $map[$key] ?? $key;
        return implode(', ', $out);
    } catch (\Throwable $e) { return $value; }
}

/**
 * Display value(s) for a STATIC value→label option map (Dropdown / Radio /
 * Switch / Checkbox / manual Lookup). CSV-aware for multi-value fields; an
 * unknown code stays raw. No DB — the map is baked in at build time.
 *
 * @param array<string,string> $map
 */
function pp_option_display_value(array $map, string $value): string
{
    if ($value === '' || $map === []) return $value;
    if (isset($map[$value])) return (string) $map[$value];
    if (strpos($value, ',') !== false) {
        $out = [];
        foreach (explode(',', $value) as $p) { $p = trim($p); if ($p !== '') $out[] = $map[$p] ?? $p; }
        return implode(', ', $out);
    }
    return $value;
}

/**
 * Replace stored codes with labels in-place across a row set using static
 * option maps (print / export). Mirrors pp_apply_lookups but needs no DB.
 *
 * @param array<string,array<string,string>> $optionMaps  field => [value => label]
 */
function pp_apply_option_maps(array &$rows, array $optionMaps): void
{
    foreach ($optionMaps as $field => $map) {
        if (!is_array($map) || $map === []) continue;
        foreach ($rows as &$r) {
            $v = (string) ($r[$field] ?? '');
            if ($v === '') continue;
            if (isset($map[$v])) { $r[$field] = (string) $map[$v]; continue; }
            if (strpos($v, ',') !== false) {
                $parts = [];
                foreach (explode(',', $v) as $p) { $p = trim($p); if ($p !== '') $parts[] = $map[$p] ?? $p; }
                $r[$field] = implode(', ', $parts);
            }
        }
        unset($r);
    }
}

/**
 * Render a paginated HTML table from a DB result set.
 */
/**
 * Resolve foreign-key columns present on this page to their display values —
 * one batched IN() query per lookup field. Shared by renderTable + renderCards.
 * @return array<string,array<string,string>>  fieldName => [rawKey => display]
 */
function pp_build_lookup_maps(array $fields, array $records): array
{
    $lookupMaps = [];
    foreach ($fields as $lf) {
        // Static value→label option map (Dropdown / Radio / Switch / …), baked
        // in at build time — no query. Merged alongside DB lookup maps.
        $om = (array) ($lf['optionMap'] ?? []);
        if ($om !== [] && ($lf['list'] ?? true)) {
            $nm = (string) ($lf['name'] ?? '');
            if ($nm !== '') $lookupMaps[$nm] = ($lookupMaps[$nm] ?? []) + $om;
        }
        $lk = (array) ($lf['lookup'] ?? []);
        if (($lk['displayAs'] ?? 'label') === 'value') continue;   // show the raw key
        $lt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['table'] ?? ''));
        $lc = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['key'] ?? 'id')) ?: 'id';
        $ld = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($lk['display'] ?? ''));
        if ($lt === '' || $ld === '' || !($lf['list'] ?? true)) continue;
        $keys = [];
        foreach ($records as $r) {
            foreach (explode(',', (string) ($r[$lf['name']] ?? '')) as $part) {
                $part = trim($part);
                if ($part !== '') $keys[$part] = true;
            }
        }
        if ($keys === []) continue;
        $in = implode(',', array_fill(0, count($keys), '?'));
        try {
            $rows = \PpPhp\Core\DB::FetchAll("SELECT `$lc` AS k, `$ld` AS d FROM `$lt` WHERE `$lc` IN ($in)", array_keys($keys)) ?: [];
            foreach ($rows as $r) { $lookupMaps[$lf['name']][(string) $r['k']] = (string) $r['d']; }
        } catch (\Throwable $e) { /* fall back to raw keys */ }
    }
    return $lookupMaps;
}

/**
 * Render a single field's display HTML for one record — the one place that
 * applies server calculate/format events, the plugin view control, lookup
 * resolution and type formatting (boolean/currency/date/datetime/textarea).
 * Shared by renderTable (grid cells) + renderCards (card values) so both stay
 * pixel-identical. $viewAs = params['viewAs'] (fieldName => control config).
 */
function pp_field_display(array $f, array $row, string $table, array $lookupMaps = [], array $viewAs = []): string
{
    $rawVal = (string) ($row[$f['name']] ?? '');
    $val = $row[$f['name']] ?? '';
    $calcKey = 'tables.' . $table . '.fields.' . $f['name'] . '.server.calculate';
    if (\PpPhp\Core\Events::has($calcKey)) {
        $val = \PpPhp\Core\Events::fire($calcKey, ['record' => $row, 'value' => $val]);
    }
    $fmtKey = 'tables.' . $table . '.fields.' . $f['name'] . '.server.format';
    $viewCtl = $viewAs[$f['name']] ?? null;
    $display = htmlspecialchars((string) $val);
    // LookupWizard is an EDIT-only control (no 'view' renderer); a lookup field's
    // list/view display is resolved by the batched $lookupMaps below. Skipping it
    // here lets the lookup branch run — otherwise the missing view handler echoes
    // the raw foreign key (e.g. "17" instead of the court name).
    $viewCtlName = is_array($viewCtl) ? (string) ($viewCtl['control'] ?? '') : '';
    if (\PpPhp\Core\Events::has($fmtKey)) $display = (string) \PpPhp\Core\Events::fire($fmtKey, ['value' => $val, 'record' => $row]);
    elseif (is_array($viewCtl) && $viewCtlName !== '' && $viewCtlName !== 'LookupWizard' && class_exists('\PpPhp\Core\Designer\FieldControl\FieldControlRuntime')) $display = \PpPhp\Core\Designer\FieldControl\FieldControlRuntime::render('view', $viewCtlName, $f['name'], $val, (array) ($viewCtl['settings'] ?? []))['html'];
    elseif (isset($lookupMaps[$f['name']]) && $rawVal !== '') {
        $__lm = $lookupMaps[$f['name']];
        if (isset($__lm[$rawVal])) $display = htmlspecialchars($__lm[$rawVal]);
        elseif (strpos($rawVal, ',') !== false) {   // multi-value lookup (CSV of keys)
            $__parts = [];
            foreach (explode(',', $rawVal) as $__p) { $__p = trim($__p); if ($__p !== '') $__parts[] = $__lm[$__p] ?? $__p; }
            $display = htmlspecialchars(implode(', ', $__parts));
        }
    }
    elseif (($f['type'] ?? '') === 'boolean') {
        // Yes/No routed through the translation catalogue instead of hard-coded English.
        $__yes = function_exists('pp_t') ? pp_t('ui.yes', 'Yes') : 'Yes';
        $__no  = function_exists('pp_t') ? pp_t('ui.no', 'No') : 'No';
        $display = $val ? '<span class="badge bg-success">' . htmlspecialchars($__yes) . '</span>' : '<span class="badge bg-secondary">' . htmlspecialchars($__no) . '</span>';
    }
    elseif (($f['type'] ?? '') === 'currency' && is_numeric($val)) $display = pp_money((float) $val);
    elseif (($f['type'] ?? '') === 'date' && $val) $display = date('M j, Y', strtotime((string) $val));
    elseif (($f['type'] ?? '') === 'datetime' && $val) $display = date('M j, Y g:i a', strtotime((string) $val));
    elseif (($f['type'] ?? '') === 'textarea') {
        // Truncate long text with an ellipsis + a title carrying the full value
        // (was a bare substr with no ellipsis and no way to read the rest).
        $__full = (string) $val;
        $__cut  = mb_substr($__full, 0, 200);
        $display = nl2br(htmlspecialchars($__cut));
        if (mb_strlen($__full) > 200) $display = '<span title="' . htmlspecialchars($__full, ENT_QUOTES) . '">' . nl2br(htmlspecialchars($__cut)) . '…</span>';
    }
    return $display;
}

/**
 * Render the per-row action buttons (view/edit/delete/copy/inline-edit) from a
 * designed action config. Returns just the button HTML (no wrapping element) so
 * the grid can drop it in a <td> and cards in a footer. Shared by both.
 */
function pp_row_actions(string $table, $id, array $actions, array $actionStyles = []): string
{
    $t = static fn(string $k, string $d): string => function_exists('pp_t') ? pp_t($k, $d) : $d;
    $items = (array) ($actions['items'] ?? []);
    if ($items === []) {
        // fallback when no designed action config exists
        foreach (['view', 'edit', 'delete'] as $k) {
            if ($actions[$k] ?? true) {
                $items[$k] = ['icon' => ['view' => 'bi-eye', 'edit' => 'bi-pencil', 'delete' => 'bi-trash'][$k], 'label' => ucfirst($k), 'danger' => $k === 'delete', 'display' => 'icon_label'];
            }
        }
    }
    // Map each row action to the permission it needs, so a user who cannot do
    // it never sees the button. The server APIs enforce it too — this just
    // keeps the UI honest.
    $actionPerm = ['view' => 'view', 'edit' => 'edit', 'delete' => 'delete',
                   'inline-edit' => 'edit', 'copy' => 'add'];
    $out = '';
    foreach ($items as $key => $meta) {
        $needed = $actionPerm[$key] ?? null;
        if ($needed !== null && class_exists('\\PpPhp\\Core\\Auth\\Permissions')
            && !\PpPhp\Core\Auth\Permissions::can($needed, $table)) {
            continue;
        }
        $iconHtml = function_exists('pp_icon') ? pp_icon((string) $meta['icon']) : '';
        $labelTxt = $t('ui.' . $key, (string) $meta['label']);
        $inner = match ((string) ($meta['display'] ?? 'icon')) {
            'icon'  => $iconHtml,
            'label' => htmlspecialchars($labelTxt),
            default => $iconHtml . ' ' . htmlspecialchars($labelTxt),
        };
        // data-pp-tip drives the app's own styled tooltip; aria-label keeps the
        // icon-only button readable to screen readers. No `title` — the native
        // bubble would double up on the styled one.
        $titleAttr = (($meta['display'] ?? 'icon') === 'icon')
            ? ' data-pp-tip="' . htmlspecialchars($labelTxt) . '" aria-label="' . htmlspecialchars($labelTxt) . '"'
            : '';
        $dangerCls = !empty($meta['danger']) ? ' pp-act-danger' : '';
        if (!empty($meta['class'])) $dangerCls .= ' ' . htmlspecialchars((string) $meta['class']);
        // per-action button styling designed on the Cards page (partStyles cact:<action>)
        $styleAttr = '';
        if (isset($actionStyles[$key]) && is_array($actionStyles[$key])) {
            $st = pp_card_part_css($actionStyles[$key], 'cact:' . $key);
            if ($st !== '') $styleAttr = ' style="' . htmlspecialchars($st) . '"';
        }
        $dataAttrs = ' data-table="' . htmlspecialchars($table) . '" data-id="' . htmlspecialchars((string) $id) . '"';
        if ($key === 'delete') {
            $out .= '<button type="button" class="ppd-action-delete' . $dangerCls . '"' . $titleAttr . $styleAttr . $dataAttrs . '>' . $inner . '</button>';
        } elseif ($key === 'inline-edit') {
            $out .= '<button type="button" class="ppd-action-inline' . $dangerCls . '"' . $titleAttr . $styleAttr . $dataAttrs . '>' . $inner . '</button>';
        } elseif ($key === 'copy') {
            $out .= '<button type="button" class="ppd-action-copy' . $dangerCls . '"' . $titleAttr . $styleAttr . $dataAttrs . '>' . $inner . '</button>';
        } else {
            $page = $key === 'edit' ? 'edit' : 'view';
            $url  = APP_BASE . '/generated/' . htmlspecialchars($table) . '/' . $page . '.php?id=' . htmlspecialchars((string) $id);
            // Open in the list's iframe modal when this page-type is set to popup.
            $popup = ($page === 'edit' && !empty($actions['editPopup'])) || ($page === 'view' && !empty($actions['viewPopup']));
            $trigger = $popup ? ' data-pp-form-modal="' . $url . '"' : ' onclick="location.href=\'' . $url . '\'"';
            $out .= '<button type="button" class="ppd-action-' . $page . $dangerCls . '"' . $titleAttr . $styleAttr . $dataAttrs . $trigger . '>' . $inner . '</button>';
        }
    }
    return $out;
}

/**
 * A card part's style object → inline CSS string (mirror of cardPartCss in the
 * Designer registry.js so the canvas preview and shipped card match exactly).
 */
function pp_card_part_css(array $s, string $key = ''): string
{
    $o = [];
    if (!empty($s['color']))       $o[] = 'color:' . $s['color'];
    if (!empty($s['background']))  $o[] = 'background:' . $s['background'];
    if (!empty($s['font']))        $o[] = 'font-family:' . $s['font'];
    if (!empty($s['fontSize']))    $o[] = 'font-size:' . $s['fontSize'];
    if (!empty($s['fontWeight']))  $o[] = 'font-weight:' . $s['fontWeight'];
    if (!empty($s['align'])) {
        // flex parts (header/footer/action buttons) align via justify-content
        if ($key === 'chead' || $key === 'cfoot' || strpos($key, 'cact') === 0) {
            $o[] = 'justify-content:' . (['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'][$s['align']] ?? $s['align']);
        } else {
            $o[] = 'text-align:' . $s['align'];
        }
    }
    if (!empty($s['padding']))     $o[] = 'padding:' . $s['padding'];
    if (!empty($s['borderStyle']) || !empty($s['borderWidth']) || !empty($s['borderColor']))
        $o[] = 'border:' . ($s['borderWidth'] ?? '1px') . ' ' . ($s['borderStyle'] ?? 'solid') . ' ' . ($s['borderColor'] ?? 'currentColor');
    if (!empty($s['borderRadius'])) $o[] = 'border-radius:' . $s['borderRadius'];
    if (!empty($s['customCss']))    $o[] = (string) $s['customCss'];
    return implode(';', $o);
}
/**
 * Class + style attributes for a card part, merging the designer's partStyles
 * (custom class + inline CSS) with an optional extra style (e.g. badge colour).
 */
function pp_card_part_attr(array $partStyles, string $key, string $baseClass, string $extraStyle = ''): string
{
    $s = (array) ($partStyles[$key] ?? []);
    $css = pp_card_part_css($s, $key);
    if ($extraStyle !== '') $css = $css !== '' ? ($css . ';' . $extraStyle) : $extraStyle;
    $cls = $baseClass;
    if (!empty($s['class'])) $cls .= ' ' . preg_replace('/[^a-zA-Z0-9 _-]/', '', (string) $s['class']);
    return ' class="' . htmlspecialchars($cls) . '"' . ($css !== '' ? ' style="' . htmlspecialchars($css) . '"' : '');
}

/** Does a record value satisfy a conditional-formatting rule operator? */
function pp_card_rule_match($val, string $op, string $cmp): bool
{
    $sv = (string) $val;
    $numeric = is_numeric($val) && is_numeric($cmp);
    switch ($op) {
        case '=':         return strcasecmp($sv, $cmp) === 0;
        case '!=':        return strcasecmp($sv, $cmp) !== 0;
        case '>':         return $numeric ? ($val + 0) >  ($cmp + 0) : strcmp($sv, $cmp) >  0;
        case '<':         return $numeric ? ($val + 0) <  ($cmp + 0) : strcmp($sv, $cmp) <  0;
        case '>=':        return $numeric ? ($val + 0) >= ($cmp + 0) : strcmp($sv, $cmp) >= 0;
        case '<=':        return $numeric ? ($val + 0) <= ($cmp + 0) : strcmp($sv, $cmp) <= 0;
        case 'contains':  return $cmp !== '' && stripos($sv, $cmp) !== false;
        case 'empty':     return $sv === '';
        case 'not empty': return $sv !== '';
    }
    return false;
}
/**
 * Evaluate the card conditional-formatting rules against one record → a
 * {part: {prop: value}} overrides map (later rules win per part/prop).
 */
function pp_card_eval_rules(array $rules, array $row): array
{
    $out = [];
    foreach ($rules as $r) {
        $field = (string) ($r['field'] ?? '');
        if ($field === '' || !pp_card_rule_match($row[$field] ?? null, (string) ($r['op'] ?? '='), (string) ($r['value'] ?? ''))) continue;
        $target = (string) ($r['target'] ?? 'cbox');
        $st = [];
        if (($r['background'] ?? '') !== '') $st['background'] = (string) $r['background'];
        if (($r['color'] ?? '') !== '')      $st['color'] = (string) $r['color'];
        if (!empty($r['bold']))              $st['fontWeight'] = '700';
        if ($st !== []) $out[$target] = array_merge($out[$target] ?? [], $st);
    }
    return $out;
}

/**
 * Server-rendered Cards view (Pages module 'cards' page-type). Builds one
 * .pp-card per record from a card template (settings.cards), reusing
 * pp_field_display/pp_build_lookup_maps/pp_row_actions so card values render
 * identically to the grid (view controls, lookups, formatting). Rendered hidden;
 * the Table/Cards toggle reveals it. Empty $tmpl → auto (first field = title).
 */
function renderCards(array $records, array $fields, string $table, array $tmpl = [], array $params = []): string
{
    $byName = [];
    $listFields = [];
    foreach ($fields as $f) {
        $byName[$f['name']] = $f;
        if ($f['list'] ?? true) $listFields[] = $f;
    }

    $titleF     = (string) ($tmpl['title'] ?? '');
    $subF       = (string) ($tmpl['subtitle'] ?? '');
    $imgF       = (string) ($tmpl['image'] ?? '');
    $badgeF     = (string) ($tmpl['badge'] ?? '');
    $badgeColors = (array) ($tmpl['badgeColors'] ?? []);
    $bodyFields = array_values(array_filter((array) ($tmpl['body'] ?? []), static fn($v) => (string) $v !== ''));
    $showLabels = ($tmpl['showLabels'] ?? true) !== false;
    $perRow     = (int) ($tmpl['perRow'] ?? 0);
    $density    = in_array(($tmpl['density'] ?? 'normal'), ['compact', 'comfortable'], true) ? (string) $tmpl['density'] : '';
    $wantActions = ($tmpl['actions'] ?? true) !== false;
    $partStyles  = (array) ($tmpl['partStyles'] ?? []);   // per-part styling from the Designer
    $cardRules   = (array) ($tmpl['cardRules'] ?? []);    // conditional formatting (per record)
    $actionStyles = [];                                   // per-action-button styling (cact:<action>)
    foreach ($partStyles as $__pk => $__pv) {
        if (strpos((string) $__pk, 'cact:') === 0 && is_array($__pv)) $actionStyles[substr((string) $__pk, 5)] = $__pv;
    }

    // No template configured → derive one: first list field is the title,
    // the remaining list fields become the body.
    if ($titleF === '' && $bodyFields === [] && $imgF === '' && $badgeF === '' && $subF === '') {
        if ($listFields !== []) {
            $titleF = (string) $listFields[0]['name'];
            foreach (array_slice($listFields, 1) as $bf) $bodyFields[] = (string) $bf['name'];
        }
    }

    $lookupMaps = pp_build_lookup_maps($fields, $records);
    $viewAs     = (array) ($params['viewAs'] ?? []);
    $actions    = (array) ($params['actions'] ?? []);
    $showActions = $wantActions && ($actions['show'] ?? true)
        && (($actions['view'] ?? true) || ($actions['edit'] ?? true) || ($actions['delete'] ?? true));
    $canView = ($actions['view'] ?? true) !== false;

    $gridStyle = $perRow > 0 ? ' style="grid-template-columns:repeat(' . $perRow . ',minmax(0,1fr))"' : '';
    $gridClass = 'ppd-cards' . ($density !== '' ? ' density-' . $density : '');
    $html = '<div class="' . $gridClass . '" id="ppCards" data-pp-cards data-pp-server="1"' . $gridStyle . ' hidden>';

    if (empty($records)) {
        $empty = function_exists('pp_t') ? pp_t('ui.no_records', 'No records found.') : 'No records found.';
        return $html . '<div class="pp-card-empty">' . htmlspecialchars($empty) . '</div></div>';
    }

    $labelOf = static function (string $name) use ($byName, $table): string {
        $lbl = $byName[$name]['label'] ?? $name;
        return htmlspecialchars(function_exists('pp_t') ? pp_t('label.' . $table . '.' . $name, (string) $lbl) : (string) $lbl);
    };
    $dispOf = static function (string $name, array $row) use ($byName, $table, $lookupMaps, $viewAs): string {
        $f = $byName[$name] ?? ['name' => $name, 'type' => ''];
        return pp_field_display($f, $row, $table, $lookupMaps, $viewAs);
    };

    foreach ($records as $row) {
        $id = $row['id'] ?? '';
        // per-record styles = base part styles + any matched conditional rules
        $rps = $partStyles;
        if ($cardRules !== []) {
            foreach (pp_card_eval_rules($cardRules, $row) as $__tk => $__tv) {
                $rps[$__tk] = array_merge((array) ($rps[$__tk] ?? []), $__tv);
            }
        }
        // data-pp-rec = the record, so the Runner API reads cards like grid rows.
        $html .= '<div' . pp_card_part_attr($rps, 'cbox', 'pp-card') . ' data-id="' . htmlspecialchars((string) $id) . '"'
            . ' data-pp-rec="' . htmlspecialchars(json_encode($row, JSON_UNESCAPED_UNICODE), ENT_QUOTES) . '">';

        if ($imgF !== '' && (string) ($row[$imgF] ?? '') !== '') {
            $html .= '<div' . pp_card_part_attr($rps, 'cmedia', 'pp-card-media') . '><img src="' . htmlspecialchars((string) $row[$imgF]) . '" alt="" loading="lazy"></div>';
        }

        $html .= '<div' . pp_card_part_attr($rps, 'chead', 'pp-card-head') . '>';
        if ($badgeF !== '' && (string) ($row[$badgeF] ?? '') !== '') {
            $bVal   = (string) $row[$badgeF];
            $bColor = $badgeColors[$bVal] ?? ($badgeColors['*'] ?? '');
            $bExtra = $bColor !== '' ? 'background:' . $bColor . ';border-color:' . $bColor . ';color:var(--pp-primary-text,#fff)' : '';
            $html .= '<span' . pp_card_part_attr($rps, 'cbadge', 'pp-card-badge', $bExtra) . '>' . $dispOf($badgeF, $row) . '</span>';
        }
        if ($titleF !== '') {
            $tHtml = $dispOf($titleF, $row);
            if ($tHtml === '') $tHtml = '<span class="pp-card-muted">&mdash;</span>';
            if ($canView) {
                $link = APP_BASE . '/generated/' . htmlspecialchars($table) . '/view.php?id=' . urlencode((string) $id);
                $tHtml = '<a href="' . $link . '">' . $tHtml . '</a>';
            }
            $html .= '<div' . pp_card_part_attr($rps, 'ctitle', 'pp-card-title') . '>' . $tHtml . '</div>';
        }
        if ($subF !== '') {
            $html .= '<div' . pp_card_part_attr($rps, 'csub', 'pp-card-subtitle') . '>' . $dispOf($subF, $row) . '</div>';
        }
        $html .= '</div>';

        if ($bodyFields !== []) {
            $html .= '<dl' . pp_card_part_attr($rps, 'cbody', 'pp-card-body') . '>';
            foreach ($bodyFields as $bn) {
                // never repeat a field already promoted into the card header
                if ($bn === $titleF || $bn === $subF || $bn === $imgF || $bn === $badgeF) continue;
                $disp = $dispOf($bn, $row);
                $html .= '<div class="pp-card-row">';
                if ($showLabels) $html .= '<dt>' . $labelOf($bn) . '</dt>';
                $html .= '<dd>' . ($disp === '' ? '<span class="pp-card-muted">&mdash;</span>' : $disp) . '</dd></div>';
            }
            $html .= '</dl>';
        }

        if ($showActions) {
            $html .= '<div' . pp_card_part_attr($rps, 'cfoot', 'pp-card-foot') . '>' . pp_row_actions($table, $id, $actions, $actionStyles) . '</div>';
        }
        $html .= '</div>';
    }
    return $html . '</div>';
}

/**
 * Preserve the current list view-state across the grid's own controls. Sort
 * links, pagination and the search / filter / per-page forms used to hard-code
 * only a param or two and SILENTLY DROP active column filters (f[]), the active
 * where-tab, per-page and searchin whenever the user sorted or paged — the list
 * appeared to un-filter itself. These rebuild the URL / hidden-field set from
 * the WHOLE $_GET (minus the key being changed).
 */
/**
 * Escape LIKE wildcards in a user value so a search for "50%" or "a_b" matches
 * literally instead of acting as wildcards (backslash is the default LIKE escape
 * on MySQL/Postgres). Wrap the RESULT in %…% at the call site.
 */
function pp_like(string $s): string
{
    return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $s);
}
/**
 * Format a currency value. The symbol + placement come from the optional
 * PP_CURRENCY / PP_CURRENCY_AFTER constants (a project can define them from a
 * regional setting); default is a leading "$" so existing apps are unchanged.
 */
function pp_money(float $v): string
{
    $sym   = defined('PP_CURRENCY') ? (string) PP_CURRENCY : '$';
    $after = defined('PP_CURRENCY_AFTER') && PP_CURRENCY_AFTER;
    $n = number_format($v, 2);
    return $after ? ($n . $sym) : ($sym . $n);
}
function pp_state_query(array $override = [], array $drop = []): string
{
    $q = $_GET;
    foreach ($drop as $k) unset($q[$k]);
    foreach ($override as $k => $v) $q[$k] = $v;
    return http_build_query($q);
}
function pp_state_hidden(array $drop = []): string
{
    $q = $_GET;
    foreach ($drop as $k) unset($q[$k]);
    $out = '';
    $walk = function ($val, string $name) use (&$walk, &$out): void {
        if (is_array($val)) { foreach ($val as $k => $v) $walk($v, $name . '[' . $k . ']'); }
        else { $out .= '<input type="hidden" name="' . htmlspecialchars($name, ENT_QUOTES) . '" value="' . htmlspecialchars((string) $val, ENT_QUOTES) . '">'; }
    };
    foreach ($q as $k => $v) $walk($v, (string) $k);
    return $out;
}

/**
 * Compare one scalar to another with a conditional-formatting operator.
 * Numeric operands compare numerically; everything else lexically.
 */
function pp_cond_match(string $val, string $op, string $cmp): bool
{
    $numeric = is_numeric($val) && is_numeric($cmp);
    switch ($op) {
        case '=':   return $numeric ? ($val + 0) == ($cmp + 0) : $val === $cmp;
        case '!=':  return $numeric ? ($val + 0) != ($cmp + 0) : $val !== $cmp;
        case '>':   return $numeric ? ($val + 0) >  ($cmp + 0) : strcmp($val, $cmp) >  0;
        case '<':   return $numeric ? ($val + 0) <  ($cmp + 0) : strcmp($val, $cmp) <  0;
        case '>=':  return $numeric ? ($val + 0) >= ($cmp + 0) : strcmp($val, $cmp) >= 0;
        case '<=':  return $numeric ? ($val + 0) <= ($cmp + 0) : strcmp($val, $cmp) <= 0;
        case 'contains':  return $cmp !== '' && stripos($val, $cmp) !== false;
        case 'empty':     return $val === '';
        case 'not empty': return $val !== '';
    }
    return false;
}
/**
 * Evaluate the grid's conditional-formatting rules against one record.
 *
 * Rule shape (Designer): {
 *   scope:'row'|'cell', column,
 *   match:'all'|'any',
 *   conditions:[ {field, op, value, vtype:'value'|'field'} ],   // vtype 'field' compares to another column
 *   style:{ background, color, bold, italic, border, badge, icon, className }
 * }
 * Backward-compatible with the original single-condition shape
 * ({condition:{…}, style:{background,color,bold}}).
 *
 * Returns:
 *   ['rowStyle'=>css, 'rowClass'=>cls,
 *    'cells'=>[ col => ['style'=>css,'class'=>cls,'badge'=>bool,'icon'=>name,'bg'=>c,'fg'=>c] ]]
 */
function pp_eval_conditional_rules(array $row, array $rules): array
{
    $out = ['rowStyle' => '', 'rowClass' => '', 'cells' => []];
    $clean    = static fn(string $c): string => preg_replace('/[^A-Za-z0-9#(),.%\- ]/', '', $c);
    $cleanCls = static fn(string $c): string => trim(preg_replace('/[^A-Za-z0-9_\- ]/', '', $c));
    $matchOne = static function (array $c, array $row): bool {
        $field = (string) ($c['field'] ?? '');
        if ($field === '' || !array_key_exists($field, $row)) return false;
        $cmp = (string) ($c['value'] ?? '');
        if (($c['vtype'] ?? 'value') === 'field') $cmp = (string) ($row[$cmp] ?? '');   // compare to another column
        return pp_cond_match((string) ($row[$field] ?? ''), (string) ($c['op'] ?? '='), $cmp);
    };
    foreach ($rules as $r) {
        if (!is_array($r)) continue;
        // New multi-condition array, or the legacy single 'condition'.
        $conds = $r['conditions'] ?? null;
        if (!is_array($conds)) $conds = isset($r['condition']) ? [$r['condition']] : [];
        $conds = array_values(array_filter($conds, 'is_array'));
        if ($conds === []) continue;
        $any = (($r['match'] ?? 'all') === 'any');
        $hit = !$any;   // AND starts true, OR starts false
        foreach ($conds as $c) {
            $ok = $matchOne($c, $row);
            if ($any) { if ($ok) { $hit = true; break; } }
            else      { if (!$ok) { $hit = false; break; } }
        }
        if (!$hit) continue;

        $st = (array) ($r['style'] ?? []);
        $bg = ($st['background'] ?? '') !== '' ? $clean((string) $st['background']) : '';
        $fg = ($st['color'] ?? '') !== '' ? $clean((string) $st['color']) : '';
        $other = '';
        if (!empty($st['bold']))   $other .= 'font-weight:700;';
        if (!empty($st['italic'])) $other .= 'font-style:italic;';
        if (($st['border'] ?? '') !== '') $other .= 'border:1px solid ' . $clean((string) $st['border']) . ';';
        $cls   = ($st['className'] ?? '') !== '' ? $cleanCls((string) $st['className']) : '';
        $badge = !empty($st['badge']);
        $icon  = preg_replace('/[^A-Za-z0-9\- ]/', '', (string) ($st['icon'] ?? ''));

        if (($r['scope'] ?? 'row') === 'cell' && ($r['column'] ?? '') !== '') {
            $col  = (string) $r['column'];
            $cell = $out['cells'][$col] ?? ['style' => '', 'class' => '', 'badge' => false, 'icon' => '', 'bg' => '', 'fg' => ''];
            // With a badge the colours paint the pill, not the whole cell.
            $colorStyle = '';
            if (!$badge) {
                if ($bg !== '') $colorStyle .= 'background:' . $bg . ';';
                if ($fg !== '') $colorStyle .= 'color:' . $fg . ';';
            }
            $cell['style'] .= $colorStyle . $other;
            if ($cls !== '')  $cell['class'] = trim($cell['class'] . ' ' . $cls);
            if ($badge) { $cell['badge'] = true; if ($bg !== '') $cell['bg'] = $bg; if ($fg !== '') $cell['fg'] = $fg; }
            if ($icon !== '') $cell['icon'] = $icon;
            $out['cells'][$col] = $cell;
        } else {
            // Row scope: colours + other on the <tr>. Badge/icon are cell-only.
            if ($bg !== '') $out['rowStyle'] .= 'background:' . $bg . ';';
            if ($fg !== '') $out['rowStyle'] .= 'color:' . $fg . ';';
            $out['rowStyle'] .= $other;
            if ($cls !== '') $out['rowClass'] = trim($out['rowClass'] . ' ' . $cls);
        }
    }
    return $out;
}

function renderTable(array $records, array $fields, string $table, array $params = []): string
{
    $gridClass = trim('ppd-grid-table ' . (string) ($params['gridClass'] ?? ''));
    $selection = (bool) ($params['selection'] ?? true);
    $singleSel = ($params['selectionMode'] ?? 'multiple') === 'single';   // radios, no select-all
    $stack     = ($params['responsive'] ?? 'scroll') === 'stack';         // mobile card-stack mode
    if ($stack) $gridClass = trim($gridClass . ' pp-resp-stack');
    $colLabels = [];   // name → header label, for the stack mode's data-label
    $actions   = (array) ($params['actions'] ?? []);
    $showActions = ($actions['show'] ?? true)
        && (($actions['view'] ?? true) || ($actions['edit'] ?? true) || ($actions['delete'] ?? true));

    // Master–Detail relations (Tables module): per-row details links with
    // inline/popup preview or a direct filtered-list icon link. Child counts
    // are batched — one GROUP BY query per relation per page.
    $details = [];
    foreach ((array) ($params['details'] ?? []) as $d) {
        $d = (array) $d;
        $d['detail'] = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($d['detail'] ?? ''));
        $d['fk'] = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($d['fk'] ?? ''));
        $d['masterField'] = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($d['masterField'] ?? '')) ?: 'id';
        if (($d['rel'] ?? '') === '' || $d['detail'] === '' || $d['fk'] === '') continue;
        $mdKeys = [];
        foreach ($records as $r) { $k = (string) ($r[$d['masterField']] ?? ''); if ($k !== '') $mdKeys[$k] = true; }
        $counts = [];
        if ($mdKeys !== []) {
            $in = implode(',', array_fill(0, count($mdKeys), '?'));
            try {
                foreach (\PpPhp\Core\DB::FetchAll("SELECT `{$d['fk']}` AS k, COUNT(*) AS c FROM `{$d['detail']}` WHERE `{$d['fk']}` IN ($in) GROUP BY `{$d['fk']}`", array_keys($mdKeys)) ?: [] as $cr) {
                    $counts[(string) $cr['k']] = (int) $cr['c'];
                }
            } catch (\Throwable $e) { /* counts stay empty */ }
        }
        $d['counts'] = $counts;
        $details[] = $d;
    }

    $wrapCls = trim('ppd-grid-table-wrap ' . (string) ($params['wrapClass'] ?? ''));
    $wrapStyle = trim((string) ($params['wrapStyle'] ?? ''));
    $html = '<div class="' . htmlspecialchars($wrapCls) . '"' . ($wrapStyle !== '' ? ' style="' . htmlspecialchars($wrapStyle) . '"' : '') . '><table class="' . htmlspecialchars($gridClass) . '" data-pp-table="' . htmlspecialchars($table) . '">';
    // Table caption (Designer grid "caption" prop) — a real <caption> for both
    // sighted users and screen readers. Absent a designed caption, emit a
    // visually-hidden one (the table name) so the grid is still announced.
    $caption = trim((string) ($params['caption'] ?? ''));
    if ($caption !== '') {
        $html .= '<caption class="pp-grid-caption">' . htmlspecialchars($caption) . '</caption>';
    } elseif (($params['captionSr'] ?? '') !== '') {
        $html .= '<caption class="pp-sr-only">' . htmlspecialchars((string) $params['captionSr']) . '</caption>';
    }
    $rowNumbers = !empty($params['rowNumbers']);   // Designer grid "rowNumbers" prop
    $rowStart   = max(0, (int) ($params['rowStart'] ?? 0));
    $headerFilterAll = !empty($params['headerFilters']);   // grid-level "Filter row" master toggle
    // Conditional formatting rules (Designer grid "conditionalRules"): each rule
    // paints a row or a target cell when a column compares true against a value.
    $condRules = (array) ($params['conditionalRules'] ?? []);
    $html .= '<thead><tr>';
    // Single-select mode → no "select all" affordance (radios pick exactly one).
    if ($selection) $html .= '<th class="ppd-grid-select" scope="col">' . ($singleSel ? '' : '<input type="checkbox" class="ppd-select-all" aria-label="' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.select_all', 'Select all') : 'Select all', ENT_QUOTES) . '">') . '</th>';
    $detailsPos = (($params['detailsPosition'] ?? 'start') === 'end') ? 'end' : 'start';
    $detailsTh = '';
    if ($details !== []) {
        $dhLabel = (string) ($params['detailsHeader'] ?? '');
        if ($dhLabel === '') $dhLabel = function_exists('pp_t') ? pp_t('ui.details', 'Details') : 'Details';
        $detailsTh = '<th class="ppd-grid-details" scope="col"' . (!empty($params['detailsStyle']) ? ' style="' . htmlspecialchars((string) $params['detailsStyle']) . '"' : '') . '>' . htmlspecialchars($dhLabel) . '</th>';
    }
    if ($rowNumbers) $html .= '<th class="pp-rownum" scope="col">#</th>';
    if ($detailsPos === 'start') $html .= $detailsTh;
    foreach ($fields as $f) {
        if (!($f['list'] ?? true)) continue;
        // Header label from the Misc Label Editor (per-language), design label as fallback.
        $label = htmlspecialchars(function_exists('pp_t')
            ? pp_t('label.' . $table . '.' . $f['name'], (string) ($f['label'] ?? $f['name']))
            : (string) ($f['label'] ?? $f['name']));
        $colLabels[$f['name']] = $label;   // reused as the cell's data-label in stack mode
        // data-col on every data header so column features (reorder / resize /
        // click-actions) can target a column by field name. (Not data-field —
        // that selector marks editable cells for spreadsheet/inline-edit.)
        $thAttr = ' data-col="' . htmlspecialchars($f['name']) . '"'
            . (!empty($f['thClass']) ? ' class="' . htmlspecialchars($f['thClass']) . '"' : '')
            . (!empty($f['thStyle']) ? ' style="' . htmlspecialchars($f['thStyle']) . '"' : '');
        // Per-column header funnel (Designer per-column "Filter" property OR the
        // grid-level "headerFilters" master toggle): opens the shared Set-Filter
        // popup for this column. Marked active when a value is applied.
        $funnel = '';
        if ((!empty($f['headerFilter']) || $headerFilterAll) && ($f['filterable'] ?? true)) {
            $hfActive = array_values(array_filter(array_map('strval', (array) ($_GET['f'][$f['name']] ?? [])), 'strlen')) !== [];
            $funnel = '<button type="button" class="pp-hf-btn' . ($hfActive ? ' is-active' : '') . '"'
                . ' data-pp-setfilter data-table="' . htmlspecialchars($table) . '" data-column="' . htmlspecialchars($f['name']) . '"'
                . ' data-label="' . $label . '" title="' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.filter', 'Filter') : 'Filter', ENT_QUOTES) . '"'
                . ' data-all-label="' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.all', 'All') : 'All', ENT_QUOTES) . '"'
                . ' data-sel-label="' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.selected', 'selected') : 'selected', ENT_QUOTES) . '"'
                . ' aria-haspopup="listbox" aria-expanded="false">'
                . '<i class="bi bi-funnel' . ($hfActive ? '-fill' : '') . ' pp-hf-ic"></i>'
                . '<i class="bi bi-caret-down-fill pp-hf-caret"></i>'
                . '<span class="pp-hf-spin" aria-hidden="true"></span></button>';
        }
        if (!($f['sortable'] ?? true)) {
            $html .= '<th' . $thAttr . ' scope="col">' . $label . $funnel . '</th>';
            continue;
        }
        $sort = $params['sort'] ?? '';
        $dir = $params['dir'] ?? 'ASC';
        $nextDir = ($sort === $f['name'] && $dir === 'ASC') ? 'DESC' : 'ASC';
        $arrow = ($sort === $f['name']) ? ($dir === 'ASC' ? ' &#9650;' : ' &#9660;') : '';
        // Preserve the FULL current view-state (search, filters f[], where-tab,
        // per-page…) across a sort — only sort/dir change and page resets to 1.
        $sortHref = '?' . pp_state_query(['sort' => $f['name'], 'dir' => $nextDir], ['page']);
        $ariaSort = ($sort === $f['name']) ? ($dir === 'ASC' ? ' aria-sort="ascending"' : ' aria-sort="descending"') : ' aria-sort="none"';
        $html .= '<th' . $thAttr . ' scope="col"' . $ariaSort . '><a href="' . htmlspecialchars($sortHref) . '">' . $label . $arrow . '</a>' . $funnel . '</th>';
    }
    if ($detailsPos === 'end') $html .= $detailsTh;
    if ($showActions) $html .= '<th class="ppd-grid-actions" scope="col">' . (function_exists('pp_t') ? pp_t('ui.actions', 'Actions') : 'Actions') . '</th>';
    $html .= '</tr></thead><tbody>';
    $inlineMeta = (bool) ($params['inlineMeta'] ?? false);
    $editableFields = (array) ($params['editableFields'] ?? []);

    // Lookup (foreign-key) fields: resolve the raw keys present on this page to
    // their display values — one batched IN() query per lookup field.
    $lookupMaps = pp_build_lookup_maps($fields, $records);

    if (empty($records)) {
        $html .= '<tr><td colspan="' . (count($fields) + 2 + ($details !== [] ? 1 : 0) + ($rowNumbers ? 1 : 0)) . '" class="ppd-grid-empty">' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.no_records', 'No records found.') : 'No records found.') . '</td></tr>';
    } else {
        $rowNum = $rowStart;
        foreach ($records as $row) {
            $id = $row['id'] ?? '';
            // Per-row record data (raw values of the listed fields) so client
            // events can read pp.grid.rowData()/selectedRecords()/onRowClick record.
            $recData = ['id' => $id];
            foreach ($fields as $rf) { if ($rf['list'] ?? true) $recData[$rf['name']] = $row[$rf['name']] ?? null; }
            $recAttr = ' data-pp-rec="' . htmlspecialchars(json_encode($recData, JSON_UNESCAPED_UNICODE), ENT_QUOTES) . '"';
            // Conditional formatting (Designer grid rules): row- and cell-level
            // styles/classes/badges for rows whose conditions match. Row style +
            // class go on <tr>; cell styles/classes/badges apply per <td> below.
            $condHit = $condRules !== [] ? pp_eval_conditional_rules($row, $condRules) : ['rowStyle' => '', 'rowClass' => '', 'cells' => []];
            $trExtra = ($condHit['rowClass'] !== '' ? ' class="' . htmlspecialchars($condHit['rowClass']) . '"' : '')
                . ($condHit['rowStyle'] !== '' ? ' style="' . htmlspecialchars($condHit['rowStyle']) . '"' : '');
            $html .= '<tr data-id="' . htmlspecialchars((string)$id) . '"' . $recAttr . $trExtra . '>';
            if ($selection) $html .= '<td class="ppd-grid-select"><input type="' . ($singleSel ? 'radio" name="pp_sel_row"' : 'checkbox"') . ' class="ppd-select-row" value="' . htmlspecialchars((string)$id) . '" aria-label="' . htmlspecialchars(function_exists('pp_t') ? pp_t('ui.select', 'Select') : 'Select', ENT_QUOTES) . '"></td>';
            if ($rowNumbers) { $rowNum++; $html .= '<td class="pp-rownum">' . $rowNum . '</td>'; }
            $detailsTd = '';
            if ($details !== []) {
                $mdLinks = [];
                foreach ($details as $d) {
                    $mdK = (string) ($row[$d['masterField']] ?? '');
                    $mdN = $d['counts'][$mdK] ?? 0;
                    if (!empty($d['hideNoRecords']) && $mdN === 0) continue;
                    $badge = '';
                    if (!empty($d['count'])) {
                        // size + shape mirror the Designer canvas (mdBadgeCss)
                        $bSz = (string) ($d['badgeSize'] ?? 'md');
                        $bSh = (string) ($d['badgeShape'] ?? 'pill');
                        $bDims = ['sm' => [9, 15], 'md' => [10, 19], 'lg' => [12, 25]][$bSz] ?? [10, 19];
                        $bCss = 'background:' . (($d['countColor'] ?? '') !== '' ? $d['countColor'] : '#64748b')
                            . ';color:' . (($d['badgeColor'] ?? '') !== '' ? $d['badgeColor'] : '#fff')
                            . ';font-size:' . $bDims[0] . 'px;';
                        if ($bSh === 'circle') {
                            $bCss .= 'width:' . $bDims[1] . 'px;height:' . $bDims[1] . 'px;min-width:0;padding:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;';
                        } else {
                            $bCss .= 'padding:' . ($bSz === 'sm' ? '0 5px' : ($bSz === 'lg' ? '2px 8px' : '1px 6px'))
                                . ';border-radius:' . ($bSh === 'pill' ? '999px' : ($bSh === 'square' ? '0' : '4px')) . ';';
                        }
                        $badge = ' <span class="badge" style="' . htmlspecialchars($bCss) . '">' . $mdN . '</span>';
                    }
                    $lstyle = ($d['linkStyle'] ?? '') !== '' ? ' style="' . htmlspecialchars((string) $d['linkStyle']) . '"' : '';
                    $ic = (string) (($d['icon'] ?? '') !== '' ? $d['icon'] : 'bi-chevron-right');
                    if (strpos($ic, ' ') === false && strpos($ic, 'bi-') === 0) $ic = 'bi ' . $ic;
                    $caret = (($d['showIcon'] ?? true) !== false) ? '<i class="' . htmlspecialchars($ic) . ' pp-md-caret"></i> ' : '';
                    if (($d['mode'] ?? 'inline') === 'icon') {
                        $mdLinks[] = '<a class="pp-md-link"' . $lstyle . ' title="' . htmlspecialchars((string) $d['label']) . '" href="' . APP_BASE . '/generated/' . $d['detail'] . '/list.php?f%5B' . $d['fk'] . '%5D=' . urlencode($mdK) . '"><i class="bi bi-diagram-3"></i>' . $badge . '</a>';
                    } else {
                        $lbl = !empty($d['single']) && ($d['label'] ?? '') === '' ? (function_exists('pp_t') ? pp_t('ui.details', 'Details') : 'Details') : (string) $d['label'];
                        if (!empty($d['noLabel'])) $lbl = '';
                        $lTitle = $lbl === '' ? ' title="' . htmlspecialchars((string) $d['label']) . '"' : '';
                        $mdLinks[] = '<a href="#" class="pp-md-link"' . $lstyle . $lTitle . ' data-pp-md-rel="' . htmlspecialchars((string) $d['rel']) . '" data-pp-md-key="' . htmlspecialchars($mdK) . '" data-pp-md-mode="' . htmlspecialchars((string) ($d['mode'] ?? 'inline')) . '">' . $caret . htmlspecialchars($lbl) . $badge . '</a>';
                    }
                }
                $detailsTd = '<td class="ppd-grid-details"' . (!empty($params['detailsAlign']) ? ' style="text-align:' . htmlspecialchars((string) $params['detailsAlign']) . ';"' : '') . '>' . implode(' &nbsp; ', $mdLinks) . '</td>';
            }
            if ($detailsPos === 'start') $html .= $detailsTd;
            foreach ($fields as $f) {
                if (!($f['list'] ?? true)) continue;
                $rawVal = (string) ($row[$f['name']] ?? '');
                $display = pp_field_display($f, $row, $table, $lookupMaps, (array) ($params['viewAs'] ?? []));
                // data-col = column identity (all cells); data-field = editable-cell
                // marker (spreadsheet / inline-edit) only.
                $colAttr = ' data-col="' . htmlspecialchars($f['name']) . '"';
                $inlineAttr = ($inlineMeta && in_array($f['name'], $editableFields, true))
                    ? ' data-field="' . htmlspecialchars($f['name']) . '" data-raw="' . htmlspecialchars($rawVal) . '"'
                    : '';
                // Conditional formatting for this cell (style/class + optional
                // badge pill / leading icon), merged with the designed td style.
                $cc = $condHit['cells'][$f['name']] ?? null;
                $cellStyle = trim((string) ($f['tdStyle'] ?? '') . ($cc['style'] ?? ''));
                $cellCls   = trim((string) ($f['tdClass'] ?? '') . ' ' . ($cc['class'] ?? ''));
                $cellHtml  = $display;
                if ($cc !== null) {
                    if (($cc['icon'] ?? '') !== '') {
                        // Accept "check-circle" or "bi-check-circle"; normalise to "bi bi-<name>".
                        $icName = preg_replace('/^bi[- ]/', '', trim((string) $cc['icon']));
                        $cellHtml = '<i class="bi bi-' . htmlspecialchars($icName) . '"></i> ' . $cellHtml;
                    }
                    if (!empty($cc['badge'])) {
                        $bStyle = (($cc['bg'] ?? '') !== '' ? 'background:' . $cc['bg'] . ';' : '') . (($cc['fg'] ?? '') !== '' ? 'color:' . $cc['fg'] . ';' : '');
                        $cellHtml = '<span class="pp-cf-badge"' . ($bStyle !== '' ? ' style="' . htmlspecialchars($bStyle) . '"' : '') . '>' . $cellHtml . '</span>';
                    }
                }
                // In stack (mobile card) mode each cell carries its column label so
                // CSS can render "Label: value" rows once the table collapses.
                $dataLabel = $stack ? ' data-label="' . ($colLabels[$f['name']] ?? htmlspecialchars((string) $f['name'])) . '"' : '';
                $html .= '<td' . $colAttr . $inlineAttr . $dataLabel . ($cellCls !== '' ? ' class="' . htmlspecialchars($cellCls) . '"' : '')
                    . ($cellStyle !== '' ? ' style="' . htmlspecialchars($cellStyle) . '"' : '') . '>' . $cellHtml . '</td>';
            }
            if ($detailsPos === 'end') $html .= $detailsTd;
            if ($showActions) {
                // Collapsible rail: the column itself only ever owns the width
                // of the kebab. The buttons live in an absolutely-positioned
                // flyout, so they cost the table no horizontal space at all.
                $html .= '<td class="ppd-grid-actions"><div class="pp-act-rail">'
                    . '<button type="button" class="pp-act-more" aria-expanded="false" aria-haspopup="true"'
                    . ' data-pp-tip="' . (function_exists('pp_t') ? htmlspecialchars(pp_t('ui.actions', 'Actions')) : 'Actions') . '"'
                    . ' aria-label="' . (function_exists('pp_t') ? htmlspecialchars(pp_t('ui.actions', 'Actions')) : 'Actions') . '">'
                    . '<span class="pp-act-dots"></span></button>'
                    . '<div class="pp-act-pop">' . pp_row_actions($table, $id, $actions) . '</div>'
                    . '</div></td>';
            }
            $html .= '</tr>';
        }
    }

    // Hidden inline-add template row (Pages module → Inline Add)
    if (!empty($params['inlineAdd'])) {
        $html .= '<tr class="pp-inline-add-row" style="display:none">';
        if ($selection) $html .= '<td class="ppd-grid-select"></td>';
        if ($details !== [] && $detailsPos === 'start') $html .= '<td></td>';
        foreach ($fields as $f) {
            if (!($f['list'] ?? true)) continue;
            $pk = (string) ($f['pickerKind'] ?? '');
            $pkAttr = $pk !== '' ? ' data-pp-picker="' . $pk . '" autocomplete="off"' : '';
            // data-col is what the column-reorder runtime matches on. Without it
            // applyOrder() sees no data cells in this row and skips it entirely,
            // so the inline-add inputs kept the ORIGINAL field order while the
            // header and data rows followed the user's drag order.
            $html .= '<td data-col="' . htmlspecialchars($f['name']) . '">' . (in_array($f['name'], $editableFields, true)
                ? '<input class="pp-inline-input" data-field="' . htmlspecialchars($f['name']) . '"' . $pkAttr . ' placeholder="' . htmlspecialchars($f['label'] ?? $f['name']) . '">'
                : '<span style="color:var(--pp-muted,#94a3b8)">&mdash;</span>') . '</td>';
        }
        if ($details !== [] && $detailsPos === 'end') $html .= '<td></td>';
        if ($showActions) {
            // No kebab here: Save/Cancel must be visible without a reveal, so
            // the rail stacks them vertically inside the same narrow column.
            $html .= '<td class="ppd-grid-actions"><div class="pp-act-rail pp-act-stack">'
                . '<button type="button" class="pp-inline-save-new" data-pp-tip="Save" aria-label="Save"><i class="bi bi-check-lg"></i></button>'
                . '<button type="button" class="pp-inline-cancel-new" data-pp-tip="Cancel" aria-label="Cancel"><i class="bi bi-x-lg"></i></button></div></td>';
        }
        $html .= '</tr>';
    }
    $html .= '</tbody>';

    // Aggregate footer row (designed per-column aggregates)
    $aggregates = (array) ($params['aggregates'] ?? []);
    if ($aggregates !== []) {
        $aggLabels = ['count' => 'Count', 'sum' => 'Σ Sum', 'avg' => 'Avg', 'min' => 'Min', 'max' => 'Max'];
        $html .= '<tfoot><tr>';
        if ($selection) $html .= '<td class="ppd-grid-foot"></td>';
        if ($details !== [] && $detailsPos === 'start') $html .= '<td class="ppd-grid-foot"></td>';
        foreach ($fields as $f) {
            if (!($f['list'] ?? true)) continue;
            $agg = $aggregates[$f['name']] ?? null;
            if (is_array($agg) && $agg['value'] !== null) {
                $val = ($agg['fn'] === 'avg' && is_numeric($agg['value'])) ? round((float) $agg['value'], 2) : $agg['value'];
                $aggLabel = trim((string) ($agg['text'] ?? '')) !== '' ? $agg['text'] : ($aggLabels[$agg['fn']] ?? '');
                $aggStyle = !empty($agg['style']) ? ' style="' . htmlspecialchars((string) $agg['style']) . '"' : '';
                $html .= '<td class="ppd-grid-foot" data-col="' . htmlspecialchars($f['name']) . '"' . $aggStyle . '><small>' . htmlspecialchars($aggLabel) . '</small> <strong>' . htmlspecialchars((string) $val) . '</strong></td>';
            } else {
                $html .= '<td class="ppd-grid-foot" data-col="' . htmlspecialchars($f['name']) . '"></td>';
            }
        }
        if ($details !== [] && $detailsPos === 'end') $html .= '<td class="ppd-grid-foot"></td>';
        if ($showActions) $html .= '<td class="ppd-grid-foot"></td>';
        $html .= '</tr></tfoot>';
    }

    $html .= '</table></div>';
    return $html;
}

/**
 * Render pagination controls.
 */
/**
 * Numbered pagination (designer-style): ‹ 1 … 4 [5] 6 … 12 › with an
 * optional class/style from the component's Style panel.
 */
function renderPagination(int $total, int $page, int $perPage, string $baseUrl, array $queryParams = [], array $opts = []): string
{
    if ($total <= $perPage) return '';
    $totalPages = (int) ceil($total / max(1, $perPage));
    $queryStr = $queryParams ? '&' . http_build_query($queryParams) : '';
    $link = static fn(int $p): string => $baseUrl . '?page=' . $p . $queryStr;

    $cls = trim('pp-pagination ' . (string) ($opts['class'] ?? ''));
    $style = trim((string) ($opts['style'] ?? ''));
    $html = '<nav class="' . htmlspecialchars($cls) . '"' . ($style !== '' ? ' style="' . htmlspecialchars($style) . '"' : '') . '>';

    $html .= $page > 1
        ? '<a class="pp-pg" href="' . $link($page - 1) . '" aria-label="Previous">&lsaquo;</a>'
        : '<span class="pp-pg disabled">&lsaquo;</span>';

    // windowed page numbers: first, last, current ±1, ellipsis between gaps
    $shown = [];
    for ($p = 1; $p <= $totalPages; $p++) {
        if ($p === 1 || $p === $totalPages || abs($p - $page) <= 1) $shown[] = $p;
    }
    $prev = 0;
    foreach ($shown as $p) {
        if ($prev !== 0 && $p > $prev + 1) $html .= '<span class="pp-pg gap">&hellip;</span>';
        $html .= $p === $page
            ? '<span class="pp-pg active">' . $p . '</span>'
            : '<a class="pp-pg" href="' . $link($p) . '">' . $p . '</a>';
        $prev = $p;
    }

    $html .= $page < $totalPages
        ? '<a class="pp-pg" href="' . $link($page + 1) . '" aria-label="Next">&rsaquo;</a>'
        : '<span class="pp-pg disabled">&rsaquo;</span>';

    if (($opts['summary'] ?? true) !== false) {
        $html .= '<span class="pp-pg-summary">' . $total . ' record' . ($total === 1 ? '' : 's') . '</span>';
    }
    return $html . '</nav>';
}

/**
 * Aggregate over the displayed page's records (grid "Aggregate over:
 * displayed records" mode). $fn: count | sum | avg | min | max.
 */
function pp_page_aggregate(array $records, string $field, string $fn): int|float|string|null
{
    $values = [];
    foreach ($records as $r) {
        $v = $r[$field] ?? null;
        if ($v !== null && $v !== '') $values[] = $v;
    }
    if ($fn === 'count') return count($values);
    $nums = array_map('floatval', array_filter($values, 'is_numeric'));
    if ($nums === []) return null;
    return match ($fn) {
        'sum' => array_sum($nums),
        'avg' => array_sum($nums) / count($nums),
        'min' => min($nums),
        'max' => max($nums),
        default => null,
    };
}

/**
 * Return CSRF hidden field HTML.
 */
function csrfField(): string
{
    return '<input type="hidden" name="_token" value="' . htmlspecialchars(\PpPhp\Core\Security::CSRF()->Token()) . '">';
}

/**
 * Render flash message if set.
 */
function flashMessage(): string
{
    if (isset($_SESSION['_flash'])) {
        $msg = htmlspecialchars($_SESSION['_flash']['message']);
        $type = htmlspecialchars($_SESSION['_flash']['type'] ?? 'info');
        unset($_SESSION['_flash']);
        return '<div class="ppd-flash ppd-flash-' . $type . '">' . $msg . '</div>';
    }
    return '';
}
RUNTIME;
        // Database authentication is OPT-IN (Security module → "Enable database
        // authentication", default off). Expose the flag to the whole app so
        // Security::RequireAuth() becomes a no-op when auth is disabled — a build
        // without auth opens straight to its pages instead of bouncing to /login.
        // A standalone (database-free) app cannot authenticate — force auth off.
        $authEnabled = !empty($this->ctx['security']['db_auth_enabled'])
            && !\PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug);
        // Two-factor availability (Security → 2FA). Per-user opt-in: these flags
        // only decide which methods the setup/challenge pages offer; nobody is
        // forced. 2FA needs auth, so it is off whenever auth is off.
        $tfa = $this->twoFactorConfig();
        $twoFaOn = $authEnabled && ($tfa['totp'] || $tfa['email'] || $tfa['sms']);
        $b = static fn(bool $v): string => $v ? 'true' : 'false';
        $q = static fn(string $v): string => var_export($v, true);
        // Field-encryption key: a per-project 32-byte secret, persisted server-side
        // so it stays stable across rebuilds (a changed key would make already-
        // encrypted data undecryptable), and baked in here — NEVER in the database.
        $encKey = $this->projectEncryptionKey($slug);
        // Audit Trail & Record Locking are configured in Misc → Advanced (the source
        // of truth); the app only logs/locks when they are enabled there, into the
        // table names configured there. Locking additionally needs auth (a user owns
        // a lock).
        $adv = (array) ($this->ctx['advanced'] ?? []);
        $auditOn    = !empty($adv['auditEnabled']);
        $auditTable = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($adv['auditTable'] ?? '')) ?: 'audit_log';
        $lockOn     = $authEnabled && !empty($adv['recordLocking']);
        $lockTable  = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($adv['lockTable'] ?? '')) ?: 'record_locks';
        $lockTtl    = max(30, (int) ($adv['lockTimeout'] ?? 300));
        // Default timezone (Misc → Advanced): the whole generated app runs on it.
        $tzRaw = (string) ($adv['timezone'] ?? '');
        $tz    = in_array($tzRaw, \DateTimeZone::listIdentifiers(), true) ? $tzRaw : '';
        // static = review in the console; dynamic = ship an admin-only page in the app.
        $auditMode  = (string) ($adv['auditMode'] ?? 'static');
        $auditMode  = in_array($auditMode, ['static', 'dynamic'], true) ? $auditMode : 'static';
        $lockMode   = (string) ($adv['lockMode'] ?? 'static');
        $lockMode   = in_array($lockMode, ['static', 'dynamic'], true) ? $lockMode : 'static';
        // Session Control (Security → Session Control): idle timeout (minutes),
        // absolute lifetime (hours), concurrent-session cap, and an optional custom
        // PHP session cookie name. Enforced by core/Auth/SessionGuard in the build.
        $sess = (array) (($this->ctx['security'] ?? [])['session'] ?? []);
        $sessTimeout  = max(0, (int) ($sess['session_timeout'] ?? 30));
        $sessLifetime = max(0, (int) ($sess['session_lifetime'] ?? 24));
        $sessConc     = max(0, (int) ($sess['max_concurrent'] ?? 5));
        $sessNameRaw  = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($sess['php_session_name'] ?? ''));
        // PHP requires a session name that starts with a letter; ignore anything else.
        $sessName     = preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', (string) $sessNameRaw) ? (string) $sessNameRaw : '';
        // Default-admin seed: when the project has no configured admin, the build
        // seeds admin/admin123 and the login page advertises it (until changed).
        $__seed    = $this->adminSeed();
        $seedUser  = $__seed['seeded'] ? (string) $__seed['username'] : '';
        $seedPass  = $__seed['seeded'] ? (string) $__seed['password'] : '';
        $content = str_replace(
            "if (!defined('BASE_PATH')) define('BASE_PATH', __DIR__);",
            "if (!defined('BASE_PATH')) define('BASE_PATH', __DIR__);\n"
            . "if (!defined('PP_ENC_KEY')) define('PP_ENC_KEY', " . $q($encKey) . ");\n"
            . "if (!defined('PP_AUDIT_ENABLED')) define('PP_AUDIT_ENABLED', " . $b($auditOn) . ");\n"
            . "if (!defined('PP_AUDIT_TABLE')) define('PP_AUDIT_TABLE', " . $q($auditTable) . ");\n"
            . "if (!defined('PP_LOCK_ENABLED')) define('PP_LOCK_ENABLED', " . $b($lockOn) . ");\n"
            . "if (!defined('PP_LOCK_TABLE')) define('PP_LOCK_TABLE', " . $q($lockTable) . ");\n"
            . "if (!defined('PP_LOCK_TIMEOUT')) define('PP_LOCK_TIMEOUT', " . $lockTtl . ");\n"
            . "if (!defined('PP_TIMEZONE')) define('PP_TIMEZONE', " . $q($tz) . ");\n"
            . "if (PP_TIMEZONE !== '') { @date_default_timezone_set(PP_TIMEZONE); }\n"
            . "if (!defined('PP_AUDIT_MODE')) define('PP_AUDIT_MODE', " . $q($auditMode) . ");\n"
            . "if (!defined('PP_LOCK_MODE')) define('PP_LOCK_MODE', " . $q($lockMode) . ");\n"
            . "if (!defined('PP_SESSION_TIMEOUT')) define('PP_SESSION_TIMEOUT', " . $sessTimeout . ");\n"
            . "if (!defined('PP_SESSION_LIFETIME')) define('PP_SESSION_LIFETIME', " . $sessLifetime . ");\n"
            . "if (!defined('PP_SESSION_MAX_CONCURRENT')) define('PP_SESSION_MAX_CONCURRENT', " . $sessConc . ");\n"
            . "if (!defined('PP_SESSION_NAME')) define('PP_SESSION_NAME', " . $q($sessName) . ");\n"
            . "if (!defined('PP_SEED_ADMIN_USER')) define('PP_SEED_ADMIN_USER', " . $q($seedUser) . ");\n"
            . "if (!defined('PP_SEED_ADMIN_PASS')) define('PP_SEED_ADMIN_PASS', " . $q($seedPass) . ");\n"
            . "if (!defined('PP_AUTH_ENABLED')) define('PP_AUTH_ENABLED', " . $b($authEnabled) . ");\n"
            . "if (!defined('PP_2FA_ENABLED')) define('PP_2FA_ENABLED', " . $b($twoFaOn) . ");\n"
            . "if (!defined('PP_2FA_TOTP')) define('PP_2FA_TOTP', " . $b($twoFaOn && $tfa['totp']) . ");\n"
            . "if (!defined('PP_2FA_EMAIL')) define('PP_2FA_EMAIL', " . $b($twoFaOn && $tfa['email']) . ");\n"
            . "if (!defined('PP_2FA_SMS')) define('PP_2FA_SMS', " . $b($twoFaOn && $tfa['sms']) . ");\n"
            . "if (!defined('PP_2FA_EMAIL_FIELD')) define('PP_2FA_EMAIL_FIELD', " . $q($tfa['email_field']) . ");\n"
            . "if (!defined('PP_2FA_PHONE_FIELD')) define('PP_2FA_PHONE_FIELD', " . $q($tfa['phone_field']) . ");",
            $content
        );
        // ── Auth abuse protection ───────────────────────────────────────
        // The policy travels into the app as plain config and is installed on
        // every request, so the lockout/throttle/password rules apply to every
        // auth page without each page having to know where settings live.
        $content .= "\n\n// ── Authentication protection policy (Security → Login security) ──\n"
            . "\PpPhp\Core\Auth\AuthGuard::configure(" . var_export($this->authPolicy(), true) . ");\n";

        // ── Authorization / permissions (Security → Permissions) ────────
        // Static mode bakes the group rights map here; dynamic mode ships an
        // empty config and the engine reads the live tables at request time;
        // off ships nothing to enforce. Emitted BEFORE the pages that use it.
        $content .= "\n// ── Permissions policy (Security → Permissions) ──\n"
            . "\PpPhp\Core\Auth\Permissions::configure(" . var_export($this->permissionsConfig(), true) . ");\n";
        // Shared server-side gate for the mutating JSON APIs (save/delete/bulk/
        // copy). Page loads are gated by Permissions::requirePage(); these
        // endpoints are called straight from JS and must guard themselves.
        $content .= <<<'PERMGATE'

function pp_perm_gate(string $action, string $table): void
{
    if (\PpPhp\Core\Auth\Permissions::can($action, $table)) {
        return;
    }
    if (!headers_sent()) {
        http_response_code(403);
        header('Content-Type: application/json');
    }
    echo json_encode([
        'success' => false,
        'error'   => 'You do not have permission to ' . $action . ' records in ' . $table . '.',
    ]);
    exit;
}

PERMGATE;

        // ── Account emails (Security → Email Templates) ──────────────────
        // The project's saved copies travel with the app; AuthEmails merges them
        // over the shipped defaults, so a template added later still works.
        $sec = (array) ($this->ctx['security'] ?? []);
        $shellLogo = (array) ((($this->ctx['shell'] ?? [])['logo']) ?? []);
        $brandName = trim((string) ($shellLogo['text'] ?? '')) ?: (string) ($this->ctx['title'] ?? 'App');
        $content .= "\n// ── Account email templates (Security → Email Templates) ──\n"
            . "\$GLOBALS['PP_AUTH_EMAILS'] = " . var_export((array) ($sec['auth_emails'] ?? []), true) . ";\n"
            . "\$GLOBALS['PP_APP_NAME'] = " . var_export($brandName, true) . ";\n"
            . <<<'MAILER'

/**
 * Send one account email by template key.
 *
 * Silently does nothing when the template is switched off in the console, so
 * callers never have to ask "is this one enabled?" before sending.
 */
function pp_auth_email(string $key, string $to, array $vars = []): array
{
    if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
        return ['success' => false, 'message' => 'No valid recipient.'];
    }
    $saved = (array) ($GLOBALS['PP_AUTH_EMAILS'] ?? []);
    if (!\PpPhp\Core\Auth\AuthEmails::isEnabled($key, $saved)) {
        return ['success' => false, 'message' => 'Template disabled.'];
    }
    $vars += [
        'app_name'  => (string) ($GLOBALS['PP_APP_NAME'] ?? 'App'),
        'login_url' => (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
            . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . APP_BASE . '/generated/auth/login.php',
        'signup_ip' => \PpPhp\Core\Auth\AuthGuard::ip(),
        'year'      => date('Y'),
    ];
    $mail = \PpPhp\Core\Auth\AuthEmails::compose($key, $vars, $saved);
    if ($mail['subject'] === '') {
        return ['success' => false, 'message' => 'Unknown template.'];
    }
    return pp_send_email($to, $mail['subject'], $mail['html'], ['action' => 'auth:' . $key]);
}

/** Absolute URL to a generated auth page, for links inside emails. */
function pp_auth_url(string $file, array $query = []): string
{
    $base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
        . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . APP_BASE . '/generated/auth/' . $file;
    return $query === [] ? $base : $base . '?' . http_build_query($query);
}

MAILER;

        // ── Two-Factor login gate ───────────────────────────────────────
        // A user who has cleared the password step but still owes a second
        // factor holds NO full session — just a "pending" marker. This gate
        // (runs on every page via _runtime) funnels them to the challenge and
        // nowhere else, so a half-authenticated request can never reach the app.
        $content .= <<<'TWOFAGATE'

if (PP_2FA_ENABLED && \PpPhp\Core\Auth\TwoFactor::pending() !== null) {
    $pp2faScript = basename((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
    $pp2faPath   = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
    // A project may serve its OWN branded challenge at a clean URL by shipping a
    // root `twofactor-route.php` that returns a base-relative path (handled in
    // public-routes.php). When present, that path becomes the sole extra route a
    // half-authenticated request may reach, and the redirect target — so the gate
    // stays fully authoritative. Absent = the platform default page.
    $pp2faCustom = '';
    if (is_file(__DIR__ . '/twofactor-route.php')) {
        $pp2faCustom = trim((string) (require __DIR__ . '/twofactor-route.php'));
    }
    $pp2faReq = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
    if (APP_BASE !== '' && strpos($pp2faReq, APP_BASE) === 0) { $pp2faReq = substr($pp2faReq, strlen(APP_BASE)); }
    $pp2faReq = '/' . ltrim($pp2faReq, '/');
    $pp2faAllowed = in_array($pp2faScript, ['two-factor-verify.php', 'logout.php'], true)
        || strpos($pp2faPath, '/generated/auth/social/') !== false
        || ($pp2faCustom !== '' && rtrim($pp2faReq, '/') === rtrim($pp2faCustom, '/'));
    if (!$pp2faAllowed) {
        $pp2faTarget = $pp2faCustom !== '' ? APP_BASE . $pp2faCustom : APP_BASE . '/generated/auth/two-factor-verify.php';
        if (!headers_sent()) header('Location: ' . $pp2faTarget);
        exit;
    }
}

TWOFAGATE;

        file_put_contents($path, $content);
        $this->log('Wrote _runtime.php');
        return [$path];
    }

    /**
     * The project's authentication protection policy, merged from the two
     * places the console stores it: Security → Login security (lockout,
     * throttles, guest-only) and Security → Registration & Passwords (the
     * password rules), which until now never reached a generated app at all.
     *
     * @return array<string, mixed>
     */
    /**
     * The project's permission configuration for the generated app.
     *
     * Mode lives in security.json (Security → Permissions). For STATIC we
     * snapshot the groups + group_permissions from the project DB into a rights
     * map keyed by group NAME (rebuild-stable, unlike ids). DYNAMIC ships only
     * the mode — the engine reads the live tables itself. OFF ships nothing.
     *
     * Which 2FA methods the build offers (Security → 2FA settings). Absent
     * settings default to TOTP on / email off — the console's own default — so a
     * project that never opened the 2FA panel still gets authenticator support.
     *
     * @return array{totp:bool,email:bool,sms:bool,email_field:string,phone_field:string,remember:bool}
     */
    private function twoFactorConfig(): array
    {
        $tf = (array) (($this->ctx['security'] ?? [])['twofactor'] ?? []);
        $methods = (array) ($tf['methods'] ?? []);
        // `enabled` may be a clean bool or, from the historic corruption, a
        // nested array like [false,false,true]; the last value is the truth.
        $flag = static function ($v, bool $default): bool {
            if ($v === null) return $default;
            while (is_array($v)) { if ($v === []) return $default; $v = end($v); }
            return (bool) $v;
        };
        $str = static function ($v, string $d): string {
            while (is_array($v)) { $v = $v === [] ? $d : end($v); }
            $v = (string) $v; return $v !== '' ? $v : $d;
        };
        return [
            'totp'        => $flag($methods['totp']['enabled'] ?? null, true),   // default on
            'email'       => $flag($methods['email']['enabled'] ?? null, false),
            'sms'         => $flag($methods['sms']['enabled'] ?? null, false),
            'email_field' => $str($methods['email']['email_field'] ?? null, 'email'),
            'phone_field' => $str($methods['sms']['phone_field'] ?? null, 'phone'),
            'remember'    => $flag($tf['remember_machine'] ?? null, false),
        ];
    }

    /**
     * @return array{mode:string, groups?:array<string,array>}
     */
    private function permissionsConfig(): array
    {
        $sec  = (array) ($this->ctx['security'] ?? []);
        $mode = (($sec['permissions'] ?? [])['mode'] ?? 'off');
        $mode = in_array($mode, ['off', 'static', 'dynamic'], true) ? (string) $mode : 'off';
        // The data tables the app actually governs (used to gate menu links so a
        // non-table page like a dashboard or custom link is never hidden).
        $govTables = array_values(array_map('strval', array_keys((array) ($this->ctx['pages'] ?? []))));
        if ($mode !== 'static') {
            return ['mode' => $mode, 'tables' => $govTables];
        }

        $groups = [];
        try {
            $rows  = \PpPhp\Core\DB::FetchAll('SELECT id, name, is_admin_group FROM `groups`');
            $perms = \PpPhp\Core\DB::FetchAll('SELECT group_id, table_name, perm_view, perm_add, perm_edit, perm_delete, perm_export, perm_import FROM `group_permissions`');
        } catch (\Throwable $e) {
            // No permission tables in the project DB yet — nothing to enforce,
            // so fall back to off rather than deny-all and brick the app.
            $this->log('Permissions: static mode selected but no group tables found — enforcement disabled for this build.');
            return ['mode' => 'off'];
        }
        $idName = [];
        foreach ((array) $rows as $g) {
            $name = mb_strtolower(trim((string) ($g['name'] ?? '')));
            if ($name === '') continue;
            $idName[(int) $g['id']] = $name;
            $groups[$name] = ['admin' => !empty($g['is_admin_group']), 'tables' => []];
        }
        foreach ((array) $perms as $p) {
            $name = $idName[(int) $p['group_id']] ?? '';
            if ($name === '') continue;
            $groups[$name]['tables'][mb_strtolower((string) $p['table_name'])] = [
                'view'   => !empty($p['perm_view']),
                'add'    => !empty($p['perm_add']),
                'edit'   => !empty($p['perm_edit']),
                'delete' => !empty($p['perm_delete']),
                'export' => !empty($p['perm_export']),
                'import' => !empty($p['perm_import']),
            ];
        }
        $this->log('Permissions: static mode — compiled ' . count($groups) . ' group(s) into the build.');
        return ['mode' => 'static', 'groups' => $groups, 'tables' => $govTables];
    }

    private function authPolicy(): array
    {
        $sec   = (array) ($this->ctx['security'] ?? []);
        $login = (array) ($sec['login_security'] ?? []);
        $reg   = (array) ($sec['registration'] ?? []);
        // The Registration page saves the policy NESTED (password_policy.min_length)
        // while the controller's defaults are FLAT (password_min_length). Read the
        // nested shape first — it is what the UI actually writes — and fall back to
        // the flat one so older projects keep working.
        $pol   = (array) ($reg['password_policy'] ?? []);
        $pget  = static function (string $nested, string $flat) use ($pol, $reg) {
            if (array_key_exists($nested, $pol)) return $pol[$nested];
            if (array_key_exists($flat, $reg))   return $reg[$flat];
            return null;
        };

        $int = static fn($v, int $d): int => (is_numeric($v) ? (int) $v : $d);
        $bool = static fn($v, bool $d): bool => is_bool($v) ? $v : (($v === null || $v === '') ? $d : (bool) $v);

        return [
            'lockout_enabled'  => $bool($login['lockout_enabled'] ?? null, true),
            'max_attempts'     => max(1, $int($login['max_attempts'] ?? null, 5)),
            'attempt_window'   => max(1, $int($login['attempt_window'] ?? null, 15)),
            'lockout_minutes'  => max(1, $int($login['lockout_minutes'] ?? null, 15)),
            'ip_max_attempts'  => max(1, $int($login['ip_max_attempts'] ?? null, 20)),
            'guest_only'       => $bool($login['guest_only'] ?? null, true),
            'register_max'     => max(0, $int($login['register_max'] ?? null, 5)),
            'register_window'  => max(1, $int($login['register_window'] ?? null, 60)),
            'forgot_max'       => max(0, $int($login['forgot_max'] ?? null, 5)),
            'forgot_window'    => max(1, $int($login['forgot_window'] ?? null, 60)),
            'remember_days'    => max(1, $int($login['remember_days'] ?? null, 30)),
            'generic_errors'   => $bool($login['generic_errors'] ?? null, true),
            // Matches the console's own default (getRegistrationSettings), so an
            // app can never allow sign-ups while the UI shows them switched off.
            'allow_registration' => $bool($reg['allow_registration'] ?? null, false),
            'password_min_length'         => max(1, $int($pget('min_length', 'password_min_length'), 8)),
            'password_require_numbers'    => $bool($pget('require_numbers', 'password_require_numbers'), false),
            'password_require_mixed_case' => $bool($pget('require_mixed_case', 'password_require_mixed_case'), false),
            'password_require_special'    => $bool($pget('require_special', 'password_require_special'), false),
            'case_insensitive_login'      => $bool($pol['case_insensitive_login'] ?? null, false),
            'password_max_length'         => max(20, $int($pget('max_length', 'password_max_length'), 200)),
            'password_block_common'       => $bool($pget('block_common', 'password_block_common'), true),
            'password_block_identity'     => $bool($pget('block_identity', 'password_block_identity'), true),
            // Required sign-up fields
            'default_role'      => preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', (string) ($reg['default_role'] ?? ''))
                                    ? (string) $reg['default_role'] : 'user',
            'require_username'  => $bool($reg['require_username'] ?? null, true),
            'require_email'     => $bool($reg['require_email'] ?? null, true),
            'require_fullname'  => $bool($reg['require_fullname'] ?? null, false),
            // Which addresses may sign up
            'email_domain_mode' => in_array(($reg['email_domain_mode'] ?? 'any'), ['any', 'allow', 'block'], true)
                ? (string) ($reg['email_domain_mode'] ?? 'any') : 'any',
            'email_domains'     => (string) ($reg['email_domains'] ?? ''),
            // Bot defence on the public forms
            'bot_trap'          => $bool($reg['bot_trap'] ?? null, true),
            'min_fill_seconds'  => max(0, $int($reg['min_fill_seconds'] ?? null, 2)),
            // Password reset
            'enable_password_reset' => $bool($reg['enable_password_reset'] ?? null, true),
            // Account activation: none | email | admin
            'activation_mode'   => in_array(($reg['activation_mode'] ?? 'none'), ['none', 'email', 'admin'], true)
                                    ? (string) ($reg['activation_mode'] ?? 'none') : 'none',
            'activation_hours'  => max(1, $int($reg['activation_hours'] ?? null, 24)),
            'admin_email'       => filter_var((string) ($reg['admin_email'] ?? ''), FILTER_VALIDATE_EMAIL) ?: '',
            // The status column is project-specific (Security → Database mapping),
            // so the app is told which column to read rather than guessing.
            'status_field'      => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($sec['status_field'] ?? 'status')) ?: 'status',
            'reset_token_hours'     => max(1, $int(($reg['password_reset']['token_expiry_hours'] ?? null), 24)),
        ];
    }

    private function generateAuthPages(string $outputDir): array
    {
        $this->log('Generating auth pages…');
        $written = [];
        $authDir = $outputDir . '/generated/auth';
        if (!is_dir($authDir)) mkdir($authDir, 0755, true);
        $socialBuild = (new SocialAuthBuilder())->build($outputDir, (array)($this->ctx['security'] ?? []));
        $written = array_merge($written, $socialBuild['files']);
        foreach ($socialBuild['warnings'] as $warning) $this->log($warning);

        // App brand from the Style designer (Layout → Logo) — passed into
        // every compiled auth page so name/logo are managed in ONE place.
        $shellLogo = (array) ((($this->ctx['shell'] ?? [])['logo']) ?? []);
        $appBrand = [
            'name' => trim((string) ($shellLogo['text'] ?? '')) ?: (string) ($this->ctx['title'] ?? 'App'),
            'image' => trim((string) ($shellLogo['image'] ?? '')),
        ];

        // ── login.php ──
        // Authentication processing remains owned by BuildWriter; presentation
        // comes from Designer → Common Pages → Login when an auth_form exists.
        $loginDesign = $this->commonDesignFor('login');
        if ($loginDesign === [] && $socialBuild['login'] !== []) {
            $loginDesign = \PpPhp\Core\Designer\TemplateFactory::make('', 'login', [], ['variant' => 'aurora'])?->toArray() ?? [];
        }
        $loginDesign = LoginPageCompiler::withProviders($loginDesign, $socialBuild['login']);
        $loginProviders = LoginPageCompiler::providers($loginDesign);
        $providerExport = var_export($loginProviders, true);
        $loginLogic = <<<'PHP'

// Signed-in users have no business on the sign-in page; bouncing them also
// stops a logged-in session being silently replaced by another account.
if (\PpPhp\Core\Auth\AuthGuard::get('guest_only') && \PpPhp\Core\Security::IsLoggedIn()) {
    header('Location: ' . APP_BASE . '/');
    exit;
}
$__lock = \PpPhp\Core\Auth\AuthGuard::loginLockState((string) ($_POST['username'] ?? ''));
if ($__lock['locked']) {
    $error = 'Too many failed sign-in attempts. Try again in '
           . \PpPhp\Core\Auth\AuthGuard::retryText($__lock['retry_after']) . '.';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $provider = preg_replace('/[^a-z0-9_-]/', '', strtolower((string) ($_POST['auth_provider'] ?? '')));
    if ($provider !== '') {
        $__allowedProviders = __LOGIN_PROVIDERS__;
        if (!in_array($provider, $__allowedProviders, true)) {
            $error = 'This sign-in provider is not available.';
        } else {
            $__dest = \PpPhp\Core\Events::fire('global.auth.socialLoginRequested', ['provider' => $provider]);
            if (is_string($__dest) && $__dest !== '') {
                header('Location: ' . $__dest);
                exit;
            }
            header('Location: ' . APP_BASE . '/generated/auth/social/start.php?provider=' . rawurlencode($provider) . '&intent=login');
            exit;
        }
    } else {
        $username = trim($_POST['username'] ?? '');
        $password = $_POST['password'] ?? '';
        $remember = !empty($_POST['remember']);
        $__hook = \PpPhp\Core\Events::fire('global.auth.beforeLogin', ['username' => $username, 'password' => $password]);
        if ($__hook === false) {
            $error = 'Login is not allowed for this account.';
        } elseif (is_string($__hook) && $__hook !== '') {
            header('Location: ' . $__hook);
            exit;
        } elseif (\PpPhp\Core\Security::CheckUsernamePassword($username, $password)) {
            // Password is right — resolve the account (id/email always available)
            // and its status separately, so a users table without a status column
            // still yields the id we need for the 2FA lookup.
            $__row = null;
            try {
                $__res = \PpPhp\Core\DB::FetchAll('SELECT id, email FROM users WHERE username = ? OR email = ? LIMIT 1', [$username, $username]);
                $__row = is_array($__res) ? ($__res[0] ?? null) : null;
            } catch (\Throwable $e) { $__row = null; }
            $__uid = (int) ($__row['id'] ?? 0);
            $__email = (string) ($__row['email'] ?? '');
            $__sf = (string) \PpPhp\Core\Auth\AuthGuard::get('status_field');
            $__st = 'active';
            if ($__sf !== '') {
                try {
                    $__sres = \PpPhp\Core\DB::FetchAll('SELECT `' . $__sf . '` AS st FROM users WHERE id = ? LIMIT 1', [$__uid]);
                    if (is_array($__sres) && isset($__sres[0]['st'])) $__st = strtolower(trim((string) $__sres[0]['st']));
                } catch (\Throwable $e) { $__st = 'active'; }
            }
            if ($__st !== '' && $__st !== 'active' && $__st !== '1') {
                $error = $__st === 'pending'
                    ? 'This account is not active yet. Please confirm your email or wait for an administrator to approve it.'
                    : 'This account has been disabled. Please contact an administrator.';
            } else {
                \PpPhp\Core\Auth\AuthGuard::clearLoginFailures($username);
                // Second factor: if the account has 2FA on, the password alone is
                // not enough. Hand off to the challenge WITHOUT granting a session;
                // it establishes the session only after the code checks out.
                if (PP_2FA_ENABLED && \PpPhp\Core\Auth\TwoFactor::isEnabled($__uid)) {
                    \PpPhp\Core\Auth\TwoFactor::setPending($username, $remember, $__uid, $__email);
                    header('Location: ' . APP_BASE . '/generated/auth/two-factor-verify.php');
                    exit;
                }
                \PpPhp\Core\Security::Login($username, $password, $remember);
                \PpPhp\Core\Auth\AuditLog::log('login', null, null, null, null, 'user: ' . $username);
                \PpPhp\Core\Auth\SessionGuard::track();
                $_SESSION['_flash'] = ['message' => 'Welcome back, ' . htmlspecialchars($username) . '!', 'type' => 'success'];
                $__dest = \PpPhp\Core\Events::fire('global.auth.afterLogin', ['user' => \PpPhp\Core\Security::CurrentUserData()]);
                header('Location: ' . ((is_string($__dest) && $__dest !== '') ? $__dest : APP_BASE . '/'));
                exit;
            }
        } else {
            \PpPhp\Core\Auth\AuthGuard::recordLoginFailure($username);
            \PpPhp\Core\Auth\AuditLog::log('login_failed', null, null, null, null, 'user: ' . $username);
            \PpPhp\Core\Events::fire('global.auth.onLoginFailed', ['username' => $username, 'reason' => 'credentials']);
            // Deliberately identical whether the account exists or not, so the
            // page cannot be used to enumerate valid usernames.
            $error = 'Invalid username or password.';
            $__after = \PpPhp\Core\Auth\AuthGuard::loginLockState($username);
            if ($__after['locked']) {
                \PpPhp\Core\Auth\AuditLog::log('account_locked', null, null, null, null, 'user: ' . $username);
                $error = 'Too many failed sign-in attempts. Try again in '
                       . \PpPhp\Core\Auth\AuthGuard::retryText($__after['retry_after']) . '.';
            } elseif ($__after['remaining'] > 0 && $__after['remaining'] <= 2) {
                $error .= ' ' . $__after['remaining'] . ' attempt' . ($__after['remaining'] === 1 ? '' : 's') . ' left.';
            }
        }
    }
}

// First-run helper: if the build seeded a default admin AND that account still
// carries the default password, advertise the credentials on the sign-in form.
// The moment the password is changed (or the account is removed), the check
// below fails and the hint disappears — so real deployments never expose it.
$__adminHint = '';
if (defined('PP_SEED_ADMIN_USER') && PP_SEED_ADMIN_USER !== '' && defined('PP_SEED_ADMIN_PASS') && PP_SEED_ADMIN_PASS !== '') {
    try {
        $__ar = \PpPhp\Core\DB::FetchAll('SELECT `password` FROM `users` WHERE `username` = ? LIMIT 1', [PP_SEED_ADMIN_USER]);
        $__ah = is_array($__ar) ? (string) ($__ar[0]['password'] ?? '') : '';
        if ($__ah !== '' && \PpPhp\Core\Security::VerifyPassword(PP_SEED_ADMIN_PASS, $__ah)) {
            $__adminHint = '<i class="bi bi-info-circle"></i> First time here? Sign in with <strong>'
                . htmlspecialchars(PP_SEED_ADMIN_USER) . '</strong> / <strong>' . htmlspecialchars(PP_SEED_ADMIN_PASS)
                . '</strong>, then change your password.';
        }
    } catch (\Throwable $e) { /* users table not ready yet — no hint */ }
}

PHP;
        $loginLogic = str_replace('__LOGIN_PROVIDERS__', $providerExport, $loginLogic);
        // ONE source of truth for how this page looks: Designer → Common Pages
        // → Login. There is no competing appearance setting any more — Security
        // → Login Security governs behaviour only.
        //
        // loginAppearanceContent() survives purely as the fallback for a project
        // with no Login design at all, because it is a far better default than
        // the unstyled form authPageContent() produces.
        $loginContent = LoginPageCompiler::supports($loginDesign)
            ? LoginPageCompiler::compile($loginDesign, $loginLogic, $appBrand)
            : $this->loginAppearanceContent($loginLogic, $appBrand, $loginProviders);

        $written[] = $this->writeFile($authDir . '/login.php', $loginContent);
        // Chrome for the 2FA pages comes from its Designer common page (if edited).
        // The login-time 2FA CHALLENGE is a template-gallery common page (like
        // Login): an `auth_form` component with pageType 'two_factor' carries the
        // chosen look, and settings.twoFactorTemplate is the picked template id.
        // The in-app 2FA setup page uses built-in defaults (not designed here).
        $tfaDesign = $this->commonDesignFor('two_factor');
        $tfaAuth = $tfaDesign ? ($this->componentProps($tfaDesign)['auth_form'] ?? []) : [];
        $tfaTpl = (string) ($tfaDesign['settings']['twoFactorTemplate'] ?? ($tfaAuth['template'] ?? 'aurora'));
        $tfaOpts = [
            'challengeTemplate' => $tfaTpl,
            'challengeAccent'   => is_string($tfaAuth['accentColor'] ?? null) ? $tfaAuth['accentColor'] : '',
        ];
        $written = array_merge($written, $this->generateTwoFactorPages($authDir, $tfaOpts));
        // ── verify.php (account activation by email) ──
        $verifyLogic = '
$message = \'\'; $error = \'\';
$token = preg_replace(\'/[^A-Za-z0-9]/\', \'\', (string) ($_GET[\'token\'] ?? \'\'));
if ($token === \'\') {
    $error = \'This confirmation link is not valid.\';
} else {
    \PpPhp\Core\DB::Query(\'CREATE TABLE IF NOT EXISTS account_activations (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, token VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, INDEX idx_aa_token (token), INDEX idx_aa_user (user_id))\');
    $row = \PpPhp\Core\DB::FetchAll(\'SELECT * FROM account_activations WHERE token = ? AND expires_at > ?\', [$token, date(\'Y-m-d H:i:s\')])[0] ?? null;
    if ($row === null) {
        // Same wording for "wrong token" and "expired token": a visitor learns
        // nothing about which accounts exist or which links were once valid.
        $error = \'This confirmation link has expired or has already been used.\';
    } else {
        $sf = (string) \PpPhp\Core\Auth\AuthGuard::get(\'status_field\');
        \PpPhp\Core\DB::Update(\'users\', [$sf => \'active\'], [\'id\' => $row[\'user_id\']]);
        // Single use: every token for this account dies with the one just spent.
        try { \PpPhp\Core\DB::Query(\'DELETE FROM account_activations WHERE user_id = ?\', [$row[\'user_id\']]); } catch (\Throwable $e) {}
        $u = \PpPhp\Core\DB::FetchAll(\'SELECT username, email, fullname FROM users WHERE id = ?\', [$row[\'user_id\']])[0] ?? [];
        if (!empty($u[\'email\'])) {
            pp_auth_email(\'welcome\', (string) $u[\'email\'], [
                \'username\' => (string) ($u[\'username\'] ?? \'\'),
                \'email\'    => (string) $u[\'email\'],
                \'fullname\' => (string) ($u[\'fullname\'] ?? ($u[\'username\'] ?? \'\')),
            ]);
        }
        \PpPhp\Core\Events::fire(\'global.auth.afterActivate\', [\'user\' => $u]);
        $message = \'Your email is confirmed — you can sign in now.\';
    }
}
';
        $written[] = $this->writeFile($authDir . '/verify.php', $this->authPageContent('Confirm your email', $verifyLogic));

        // ── logout.php ──
        // ── no-access.php (permission-denied landing) ──
        // A standalone page, NOT authPageContent (which always renders a login
        // form). Reached when Permissions::requirePage() denies a signed-in user.
        $noAccessHead = $this->authHeadTags();
        $noAccess = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\$message = (\$_SESSION['_flash']['message'] ?? '') ?: 'You do not have permission to view that page.';
unset(\$_SESSION['_flash']);
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>No access</title>$noAccessHead</head>
<body class="ppd-auth-page">
<div class="ppd-auth-form" style="text-align:center">
<p style="font-size:2.75rem;margin:0 0 .5rem">&#128274;</p>
<h2 style="margin:0 0 .5rem">No access</h2>
<p style="color:var(--pp-muted,#64748b)"><?= htmlspecialchars(\$message) ?></p>
<p style="margin-top:1.25rem"><a class="ppd-auth-action" style="display:inline-block;text-decoration:none;padding:.6rem 1.2rem" href="<?= APP_BASE ?>/">Back to home</a></p>
</div></body></html>
PHP;
        $written[] = $this->writeFile($authDir . '/no-access.php', $noAccess);

        $written[] = $this->writeFile($authDir . '/logout.php', <<<'PHP'
<?php
require_once __DIR__ . '/../../_runtime.php';
$__user = \PpPhp\Core\Security::CurrentUserData();
if (\PpPhp\Core\Events::fire('global.auth.beforeLogout', ['user' => $__user]) === false) {
    header('Location: ' . APP_BASE . '/');
    exit;
}
\PpPhp\Core\Auth\AuditLog::log('logout', null, null, null, null, 'user: ' . (string) ($__user['username'] ?? ''));
\PpPhp\Core\Auth\SessionGuard::untrack();
\PpPhp\Core\Security::Logout();
\PpPhp\Core\Events::fire('global.auth.afterLogout', ['user' => $__user]);
$_SESSION['_flash'] = ['message' => 'You have been logged out.', 'type' => 'info'];
header('Location: ' . APP_BASE . '/generated/auth/login.php');
exit;

PHP);

        // ── register.php ──
        // Same split as login: Designer owns presentation (auth_form with
        // pageType 'register'), BuildWriter owns the registration POST logic.
        $registerDesign = $this->commonDesignFor('register');
        if ($registerDesign === [] && $socialBuild['register'] !== []) {
            $registerDesign = \PpPhp\Core\Designer\TemplateFactory::make('', 'register', [], ['variant' => 'aurora'])?->toArray() ?? [];
        }
        $registerDesign = LoginPageCompiler::withProviders($registerDesign, $socialBuild['register']);
        $registerProviders = LoginPageCompiler::providers($registerDesign);
        $registerProviderBranch = '';
        if ($registerProviders !== []) {
            $registerProviderBranch = <<<'PHP'
    $provider = preg_replace('/[^a-z0-9_-]/', '', strtolower((string) ($_POST['auth_provider'] ?? '')));
    if ($provider !== '') {
        $__allowedProviders = __REGISTER_PROVIDERS__;
        if (!in_array($provider, $__allowedProviders, true)) {
            $error = 'This sign-up provider is not available.';
        } else {
            $__dest = \PpPhp\Core\Events::fire('global.auth.socialLoginRequested', ['provider' => $provider, 'intent' => 'register']);
            if (is_string($__dest) && $__dest !== '') {
                header('Location: ' . $__dest);
                exit;
            }
            header('Location: ' . APP_BASE . '/generated/auth/social/start.php?provider=' . rawurlencode($provider) . '&intent=register');
            exit;
        }
    } else
PHP;
            $registerProviderBranch = str_replace('__REGISTER_PROVIDERS__', var_export($registerProviders, true), $registerProviderBranch);
        }
        $registerLogic = '
$error = \'\';
if (\PpPhp\Core\Auth\AuthGuard::get(\'guest_only\') && \PpPhp\Core\Security::IsLoggedIn()) {
    header(\'Location: \' . APP_BASE . \'/\'); exit;
}
if (!\PpPhp\Core\Auth\AuthGuard::get(\'allow_registration\')) {
    // Self-registration is off (Security → Registration & Passwords). The page
    // must refuse the POST too, not merely hide the form.
    $error = \'Registration is currently closed.\';
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\' && \PpPhp\Core\Auth\AuthGuard::tooMany(\'register\', (int) \PpPhp\Core\Auth\AuthGuard::get(\'register_max\'), (int) \PpPhp\Core\Auth\AuthGuard::get(\'register_window\'))) {
    $error = \'Too many sign-up attempts from this network. Please try again later.\';
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\') {
' . $registerProviderBranch . '    {
    $username = trim($_POST[\'username\'] ?? \'\');
    $email = trim($_POST[\'email\'] ?? \'\');
    $password = $_POST[\'password\'] ?? \'\';
    $confirm = $_POST[\'confirm_password\'] ?? \'\';
    \PpPhp\Core\Auth\AuthGuard::hit(\'register\');
    $fullname = trim($_POST[\'fullname\'] ?? \'\');
    $__missing = [];
    if (\PpPhp\Core\Auth\AuthGuard::get(\'require_username\') && $username === \'\') $__missing[] = \'username\';
    if (\PpPhp\Core\Auth\AuthGuard::get(\'require_email\')    && $email === \'\')    $__missing[] = \'email\';
    if (\PpPhp\Core\Auth\AuthGuard::get(\'require_fullname\') && $fullname === \'\') $__missing[] = \'full name\';
    if (\PpPhp\Core\Auth\AuthGuard::looksAutomated($_POST)) {
        // Honeypot filled or submitted implausibly fast. Same wording as a
        // normal validation error so a bot learns nothing from the response.
        $error = \'Your sign-up could not be processed. Please try again.\';
    } elseif ($__missing !== []) {
        $error = \'Please provide your \' . implode(\', \', $__missing) . \'.\';
    } elseif ($email !== \'\' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $error = \'Enter a valid email address.\';
    } elseif ($email !== \'\' && !\PpPhp\Core\Auth\AuthGuard::emailAllowed($email)) {
        $error = \'That email domain is not allowed to register.\';
    } elseif ($password !== $confirm) {
        $error = \'Passwords do not match.\';
    } elseif (($__pwErr = \PpPhp\Core\Auth\AuthGuard::passwordError($password, [\'username\' => $username, \'email\' => $email, \'fullname\' => $fullname])) !== \'\') {
        $error = $__pwErr;
    } else {
        $existing = \PpPhp\Core\DB::FetchAll(\'SELECT id FROM users WHERE username = ? OR email = ?\', [$username, $email]);
        if (!empty($existing)) {
            $error = \'Username or email already exists.\';
        } else {
            $__reg = [\'username\' => $username, \'email\' => $email];
            $__hook = \PpPhp\Core\Events::fire(\'global.auth.beforeRegister\', [\'data\' => &$__reg]);
            if ($__hook === false) {
                $error = \'Registration is not allowed.\';
            } elseif (is_array($__hook) && $__hook) {
                $error = implode(\' — \', array_map(static fn($f, $m) => "$f: $m", array_keys($__hook), $__hook));
            } else {
                $username = $__reg[\'username\'] ?? $username;
                $email = $__reg[\'email\'] ?? $email;
                $hash = \PpPhp\Core\Security::HashPassword($password);
                $__mode = (string) \PpPhp\Core\Auth\AuthGuard::get(\'activation_mode\');
                $__statusField = (string) \PpPhp\Core\Auth\AuthGuard::get(\'status_field\');
                if ($__mode !== \'none\') {
                    // Older databases have status ENUM(\'active\',\'inactive\',\'banned\'),
                    // which cannot store \'pending\' — the INSERT would fail outright
                    // under strict mode. Widen it once, harmlessly, before use.
                    try {
                        $__col = \PpPhp\Core\DB::FetchAll("SHOW COLUMNS FROM users LIKE \'" . $__statusField . "\'")[0] ?? null;
                        $__type = strtolower((string) ($__col[\'Type\'] ?? \'\'));
                        if ($__col !== null && str_starts_with($__type, \'enum\') && !str_contains($__type, "\'pending\'")) {
                            \PpPhp\Core\DB::Exec(\'ALTER TABLE `users` MODIFY `\' . $__statusField
                                . \'` ENUM(\\\'active\\\',\\\'pending\\\',\\\'inactive\\\',\\\'banned\\\') DEFAULT \\\'active\\\'\');
                        }
                    } catch (\Throwable $e) { /* leave the schema alone and let the insert speak */ }
                }
                $id = \PpPhp\Core\DB::Insert(\'users\', [
                    \'username\' => $username,
                    \'email\' => $email,
                    \'password\' => $hash,
                    // Self-registration creates an ORDINARY user. This used to
                    // insert \'admin\', which handed full privileges to anyone
                    // who could reach the public sign-up form.
                    \'role\' => (string) \PpPhp\Core\Auth\AuthGuard::get(\'default_role\'),
                    // An account awaiting verification or approval must NOT be
                    // active, or the whole activation step is decorative.
                    $__statusField => ($__mode === \'none\' ? \'active\' : \'pending\'),
                    \'created_at\' => date(\'Y-m-d H:i:s\'),
                ]);
                if ($id && $__mode !== \'none\') {
                    $__vars = [\'username\' => $username, \'email\' => $email, \'fullname\' => ($fullname !== \'\' ? $fullname : $username)];
                    if ($__mode === \'email\') {
                        $__hours = (int) \PpPhp\Core\Auth\AuthGuard::get(\'activation_hours\');
                        $__tok = \PpPhp\Core\Security::GenerateToken();
                        \PpPhp\Core\DB::Query(\'CREATE TABLE IF NOT EXISTS account_activations (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, token VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, INDEX idx_aa_token (token), INDEX idx_aa_user (user_id))\');
                        \PpPhp\Core\DB::Insert(\'account_activations\', [
                            \'user_id\' => $id, \'token\' => $__tok,
                            \'expires_at\' => date(\'Y-m-d H:i:s\', time() + 3600 * $__hours),
                            \'created_at\' => date(\'Y-m-d H:i:s\'),
                        ]);
                        pp_auth_email(\'verify_email\', $email, $__vars + [
                            \'verify_url\' => pp_auth_url(\'verify.php\', [\'token\' => $__tok]),
                            \'expires_in\' => $__hours . \' hours\',
                        ]);
                        $_SESSION[\'_flash\'] = [\'message\' => \'Almost there — check your inbox for a confirmation link.\', \'type\' => \'success\'];
                    } else {
                        pp_auth_email(\'awaiting_approval\', $email, $__vars);
                        $__admin = (string) \PpPhp\Core\Auth\AuthGuard::get(\'admin_email\');
                        if ($__admin !== \'\') {
                            pp_auth_email(\'admin_new_signup\', $__admin, $__vars + [
                                \'review_url\' => pp_auth_url(\'login.php\'),
                                \'signup_at\'  => date(\'j M Y, H:i\'),
                            ]);
                        }
                        $_SESSION[\'_flash\'] = [\'message\' => \'Thanks! An administrator will review your account shortly.\', \'type\' => \'success\'];
                    }
                    \PpPhp\Core\Events::fire(\'global.auth.afterRegister\', [\'user\' => [\'id\' => $id, \'username\' => $username, \'email\' => $email]]);
                    header(\'Location: \' . APP_BASE . \'/generated/auth/login.php\');
                    exit;
                }
                if ($id) {
                    pp_auth_email(\'welcome\', $email, [\'username\' => $username, \'email\' => $email, \'fullname\' => ($fullname !== \'\' ? $fullname : $username)]);
                    \PpPhp\Core\Security::Login($username, $password);
                    $__dest = \PpPhp\Core\Events::fire(\'global.auth.afterRegister\', [\'user\' => \PpPhp\Core\Security::CurrentUserData()]);
                    $_SESSION[\'_flash\'] = [\'message\' => \'Account created successfully!\', \'type\' => \'success\'];
                    header(\'Location: \' . ((is_string($__dest) && $__dest !== \'\') ? $__dest : \'/\'));
                    exit;
                }
                $error = \'Registration failed. Please try again.\';
            }
        }
    }
    // Events → global.auth.onRegistrationFailed (any validation/persistence error)
    if ($error !== \'\') {
        \PpPhp\Core\Events::fire(\'global.auth.onRegistrationFailed\', [
            \'data\'   => [\'username\' => $username ?? \'\', \'email\' => $email ?? \'\'],
            \'errors\' => [\'error\' => $error],
        ]);
    }
    }
}
';
        $registerContent = LoginPageCompiler::supports($registerDesign)
            ? LoginPageCompiler::compileRegister($registerDesign, $registerLogic, $appBrand)
            : $this->authPageContent('register', $registerLogic);
        $written[] = $this->writeFile($authDir . '/register.php', $this->injectBotTrap($registerContent));

        // ── forgot-password.php ──
        $forgotLogic = '
$message = \'\';
// One wording for every outcome. Saying "sent" for a real address and
// "if that email is registered" for an unknown one turns this page into an
// oracle for which accounts exist.
$__forgotSame = \'If that email is registered, a reset link has been sent.\';
if (!\PpPhp\Core\Auth\AuthGuard::get(\'enable_password_reset\')) {
    $message = \'Password reset is not available. Please contact an administrator.\';
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\' && \PpPhp\Core\Auth\AuthGuard::looksAutomated($_POST)) {
    $message = $__forgotSame;   // bot: answer identically, do nothing
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\' && \PpPhp\Core\Auth\AuthGuard::tooMany(\'forgot\', (int) \PpPhp\Core\Auth\AuthGuard::get(\'forgot_max\'), (int) \PpPhp\Core\Auth\AuthGuard::get(\'forgot_window\'))) {
    $message = $__forgotSame;   // silently drop it — still no oracle
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\') {
    \PpPhp\Core\Auth\AuthGuard::hit(\'forgot\');
    $email = trim($_POST[\'email\'] ?? \'\');
    $user = \PpPhp\Core\DB::FetchAll(\'SELECT id, username FROM users WHERE email = ?\', [$email]);
    if (!empty($user)) {
        $__pwUser = $user[0];
        // Events → global.auth.beforePasswordReminder: return false to skip sending.
        $__pwMsg = \'\';
        if (\PpPhp\Core\Events::fire(\'global.auth.beforePasswordReminder\', [\'user\' => $__pwUser, \'message\' => &$__pwMsg]) === false) {
            $message = $__pwMsg !== \'\' ? $__pwMsg : $__forgotSame;
        } else {
            $token = \PpPhp\Core\Security::GenerateToken();
            // Self-heal: databases created before this feature (or seeded without
            // database.sql) may lack the table the reset flow depends on.
            \PpPhp\Core\DB::Query(\'CREATE TABLE IF NOT EXISTS password_resets (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, token VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, INDEX idx_pr_token (token), INDEX idx_pr_user (user_id))\');
            \PpPhp\Core\DB::Insert(\'password_resets\', [
                \'user_id\' => $__pwUser[\'id\'],
                \'token\' => $token,
                \'expires_at\' => date(\'Y-m-d H:i:s\', time() + 3600 * max(1, (int) \PpPhp\Core\Auth\AuthGuard::get(\'reset_token_hours\'))),
                \'created_at\' => date(\'Y-m-d H:i:s\'),
            ]);
            // Send the reset email through the configured mailer (Misc → Email)
            if (function_exists(\'pp_send_email\')) {
                $__link = (isset($_SERVER[\'HTTPS\']) ? \'https\' : \'http\') . \'://\' . ($_SERVER[\'HTTP_HOST\'] ?? \'localhost\')
                    . APP_BASE . \'/generated/auth/reset-password.php?token=\' . urlencode($token);
                pp_send_email($email, \'Password Reset\',
                    \'<p>Hello \' . htmlspecialchars($__pwUser[\'username\']) . \',</p><p>Click the link below to reset your password. It expires in 1 hour.</p><p><a href="\' . $__link . \'">\' . $__link . \'</a></p>\',
                    [\'action\' => \'password_reset\']);
            }
            // Events → global.auth.afterPasswordReminder
            \PpPhp\Core\Events::fire(\'global.auth.afterPasswordReminder\', [\'user\' => $__pwUser]);
            $message = $__forgotSame;
        }
    } else {
        $message = $__forgotSame;
    }
}
';
        $forgotDesign = $this->commonDesignFor('forgot_password');
        $written[] = $this->writeFile(
            $authDir . '/forgot-password.php',
            $this->injectBotTrap(LoginPageCompiler::supports($forgotDesign)
                ? LoginPageCompiler::compileForgot($forgotDesign, $forgotLogic, $appBrand)
                : $this->authFormPage('Forgot Password', $forgotLogic, <<<'HTML'
<p style="color:var(--pp-muted,#64748b);font-size:.85rem;margin-top:0">Enter your account email and we will send you a reset link.</p>
<form method="post">
<?= csrfField() ?>
<label><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
<button class="ppd-auth-action" type="submit">Send reset link</button>
</form>
HTML, '<p style="margin-top:1rem;text-align:center"><a href="<?= APP_BASE ?>/generated/auth/login.php">Back to sign in</a></p>'))
        );

        // ── change-password.php ── (functional replacement for the static
        // designer artifact the manifest writes — logged-in users only).
        // Designed page (password_form component) wins; plain fallback otherwise.
        $cpDesign = $this->commonDesignFor('change_password');
        if (ChangePasswordPageCompiler::supports($cpDesign)) {
            $written[] = $this->writeFile($authDir . '/change-password.php', ChangePasswordPageCompiler::compile($cpDesign));
        } else
        $written[] = $this->writeFile($authDir . '/change-password.php', $this->authFormPage('Change Password', '
\PpPhp\Core\Security::RequireAuth();
$user = \PpPhp\Core\Security::CurrentUserData();
$error = \'\';
if ($_SERVER[\'REQUEST_METHOD\'] === \'POST\') {
    $current = (string) ($_POST[\'current_password\'] ?? \'\');
    $new = (string) ($_POST[\'password\'] ?? \'\');
    $confirm = (string) ($_POST[\'confirm_password\'] ?? \'\');
    $row = \PpPhp\Core\DB::FetchAll(\'SELECT password FROM users WHERE id = ?\', [$user[\'id\']])[0] ?? null;
    if (!$row || !password_verify($current, (string) $row[\'password\'])) {
        $error = \'Your current password is incorrect.\';
    } elseif (strlen($new) < 6) {
        $error = \'New password must be at least 6 characters.\';
    } elseif ($new !== $confirm) {
        $error = \'New passwords do not match.\';
    } else {
        \PpPhp\Core\DB::Update(\'users\', [\'password\' => \PpPhp\Core\Security::HashPassword($new)], [\'id\' => $user[\'id\']]);
        \PpPhp\Core\Events::fire(\'global.auth.afterPasswordChange\', [\'user\' => $user]);
        $_SESSION[\'_flash\'] = [\'message\' => \'Password changed successfully.\', \'type\' => \'success\'];
        header(\'Location: \' . APP_BASE . \'/generated/auth/profile.php\');
        exit;
    }
}
', <<<'HTML'
<form method="post">
<?= csrfField() ?>
<label><span>Current password</span><input name="current_password" type="password" autocomplete="current-password" required></label>
<label><span>New password</span><input name="password" type="password" autocomplete="new-password" required></label>
<label><span>Confirm new password</span><input name="confirm_password" type="password" autocomplete="new-password" required></label>
<button class="ppd-auth-action" type="submit">Change password</button>
</form>
HTML, '<p style="margin-top:1rem;text-align:center"><a href="<?= APP_BASE ?>/generated/auth/profile.php">Back to profile</a></p>'));

        // ── reset-password.php ── (token flow completing forgot-password)
        $resetLogic = '
$error = \'\';
$token = (string) ($_POST[\'token\'] ?? $_GET[\'token\'] ?? \'\');
$reset = null;
if ($token !== \'\') {
    try {
        $reset = \PpPhp\Core\DB::FetchAll(\'SELECT * FROM password_resets WHERE token = ? AND expires_at > ?\', [$token, date(\'Y-m-d H:i:s\')])[0] ?? null;
    } catch (\Throwable $e) { $reset = null; }
}
if ($reset === null) {
    $error = \'This reset link is invalid or has expired. Please request a new one.\';
} elseif ($_SERVER[\'REQUEST_METHOD\'] === \'POST\') {
    $new = (string) ($_POST[\'password\'] ?? \'\');
    $confirm = (string) ($_POST[\'confirm_password\'] ?? \'\');
    $__who = \PpPhp\Core\DB::FetchAll(\'SELECT username, email, fullname FROM users WHERE id = ?\', [$reset[\'user_id\']])[0] ?? [];
    if (($__pwErr = \PpPhp\Core\Auth\AuthGuard::passwordError($new, $__who)) !== \'\') {
        $error = $__pwErr;
    } elseif ($new !== $confirm) {
        $error = \'Passwords do not match.\';
    } else {
        $__rsUser = \PpPhp\Core\DB::FetchAll(\'SELECT * FROM users WHERE id = ?\', [$reset[\'user_id\']])[0] ?? [\'id\' => $reset[\'user_id\']];
        // Events → global.auth.beforePasswordReset (about to change the password)
        \PpPhp\Core\Events::fire(\'global.auth.beforePasswordReset\', [\'user\' => $__rsUser]);
        \PpPhp\Core\DB::Update(\'users\', [\'password\' => \PpPhp\Core\Security::HashPassword($new)], [\'id\' => $reset[\'user_id\']]);
        try { \PpPhp\Core\DB::Query(\'DELETE FROM password_resets WHERE user_id = ?\', [$reset[\'user_id\']]); } catch (\Throwable $e) {}
        // A reset is how someone recovers a COMPROMISED account, so every
        // long-lived "remember me" token for that user has to die with the old
        // password — otherwise the attacker keeps their cookie and stays in.
        try { \PpPhp\Core\DB::Query(\'DELETE FROM remember_tokens WHERE user_id = ?\', [$reset[\'user_id\']]); } catch (\Throwable $e) {}
        // The failed-attempt lock is cleared too: the legitimate owner just
        // proved control of the mailbox and should not stay locked out.
        try {
            $__u = \PpPhp\Core\DB::FetchAll(\'SELECT username FROM users WHERE id = ?\', [$reset[\'user_id\']])[0][\'username\'] ?? \'\';
            if ($__u !== \'\') \PpPhp\Core\Auth\AuthGuard::clearLoginFailures((string) $__u);
        } catch (\Throwable $e) {}
        // Events → global.auth.afterPasswordReset (password changed via the reset link)
        \PpPhp\Core\Events::fire(\'global.auth.afterPasswordReset\', [\'user\' => $__rsUser]);
        $_SESSION[\'_flash\'] = [\'message\' => \'Password reset successfully. Please sign in.\', \'type\' => \'success\'];
        header(\'Location: \' . APP_BASE . \'/generated/auth/login.php\');
        exit;
    }
}
';
        $resetDesign = $this->commonDesignFor('reset_password');
        $written[] = $this->writeFile(
            $authDir . '/reset-password.php',
            LoginPageCompiler::supports($resetDesign)
                ? LoginPageCompiler::compileReset($resetDesign, $resetLogic, $appBrand)
                : $this->authFormPage('Reset Password', $resetLogic, <<<'HTML'
<?php if ($reset !== null): ?>
<form method="post">
<?= csrfField() ?>
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<label><span>New password</span><input name="password" type="password" autocomplete="new-password" required></label>
<label><span>Confirm new password</span><input name="confirm_password" type="password" autocomplete="new-password" required></label>
<button class="ppd-auth-action" type="submit">Reset password</button>
</form>
<?php else: ?>
<p style="text-align:center"><a class="ppd-auth-action" style="display:inline-block;text-decoration:none;padding:.6rem 1.2rem" href="<?= APP_BASE ?>/generated/auth/forgot-password.php">Request a new link</a></p>
<?php endif; ?>
HTML, '<p style="margin-top:1rem;text-align:center"><a href="<?= APP_BASE ?>/generated/auth/login.php">Back to sign in</a></p>')
        );

        // ── user-info.php ── (the profile page owns account info)
        $written[] = $this->writeFile($authDir . '/user-info.php', <<<'PHP'
<?php
require_once __DIR__ . '/../../_runtime.php';
header('Location: ' . APP_BASE . '/generated/auth/profile.php');
exit;

PHP);

        // ── profile.php ──
        // Presentation from Designer → Common Pages → Profile when an
        // account_profile component exists; legacy minimal page otherwise.
        $profileDesign = $this->commonDesignFor('profile');
        if (ProfilePageCompiler::supports($profileDesign)) {
            $written[] = $this->writeFile($authDir . '/profile.php', ProfilePageCompiler::compile($profileDesign));
        } else
        $written[] = $this->writeFile($authDir . '/profile.php', '<?php
require_once __DIR__ . \'/../../_runtime.php\';
\PpPhp\Core\Security::RequireAuth();
$user = \PpPhp\Core\Security::CurrentUserData();
$saved = false;
if ($_SERVER[\'REQUEST_METHOD\'] === \'POST\') {
    if (!empty($_POST[\'fullname\'])) {
        \PpPhp\Core\DB::Update(\'users\', [\'fullname\' => $_POST[\'fullname\']], [\'id\' => $user[\'id\']]);
        \PpPhp\Core\Security::SetDisplayName($_POST[\'fullname\']);
        $saved = true;
    }
}
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Profile</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"></head>
<body class="ppd-auth-page">
<div class="ppd-auth-form">
<h2>My Profile</h2>
<?php if ($saved): ?><div class="alert alert-success">Profile updated.</div><?php endif; ?>
<div class="ppd-auth-display"><span>Username</span><strong>' . '<?= htmlspecialchars($user[\'username\'] ?? \'\') ?>' . '</strong></div>
<div class="ppd-auth-display"><span>Email</span><strong>' . '<?= htmlspecialchars($user[\'email\'] ?? \'\') ?>' . '</strong></div>
<div class="ppd-auth-display"><span>Role</span><strong>' . '<?= htmlspecialchars($user[\'role\'] ?? \'\') ?>' . '</strong></div>
<form method="post">
<label><span>Full Name</span><input name="fullname" value="' . '<?= htmlspecialchars($user[\'fullname\'] ?? \'\') ?>' . '"></label>
<button class="ppd-auth-action" type="submit">Update Profile</button>
</form>
<p style="margin-top:1rem;text-align:center"><a href="<?= APP_BASE ?>/">Back to Dashboard</a></p>
</div></body></html>');

        // ── access-denied.php ──
        $written[] = $this->writeFile($authDir . '/access-denied.php', '<?php
require_once __DIR__ . \'/../../_runtime.php\';
http_response_code(403);
?><!DOCTYPE html><html lang="en">
<head><meta charset="utf-8"><title>Access Denied</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"></head>
<body class="ppd-auth-page"><div class="ppd-auth-form"><h2>403 - Access Denied</h2><p>You do not have permission to access this page.</p><a href="<?= APP_BASE ?>/" class="ppd-auth-action">Back to Home</a></div></body></html>');

        return $written;
    }

    /**
     * Styled standalone auth page with a caller-supplied form body — unlike
     * authPageContent (fixed username/password fields, login-shaped), this
     * fits any auth flow: email-only, token reset, change password…
     */
    private function authFormPage(string $title, string $phpLogic, string $formInner, string $footer = ''): string
    {
        $titleEsc = addslashes($title);
        return <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\$error = \$error ?? '';
\$message = \$message ?? '';
$phpLogic
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$titleEsc</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"></head>
<body class="ppd-auth-page">
<div class="ppd-auth-form">
<h2>$titleEsc</h2>
<?= flashMessage() ?>
<?php if (\$error): ?><div class="alert alert-danger"><?= htmlspecialchars(\$error) ?></div><?php endif; ?>
<?php if (\$message): ?><div class="alert alert-info"><?= htmlspecialchars(\$message) ?></div><?php endif; ?>
$formInner
$footer
</div></body></html>
PHP;
    }

    /**
     * The generated sign-in page, driven by Security → Settings → Login Form
     * Appearance. Used whenever Designer → Common Pages → Login has no design
     * of its own, which is the default for every new project.
     *
     * Everything is emitted as inline CSS custom properties on the page root so
     * the look survives without a build-time stylesheet, and falls back to the
     * app theme tokens when a value is left unset.
     *
     * @param list<string> $providers social sign-in providers already resolved
     */
    private function loginAppearanceContent(string $phpLogic, array $appBrand, array $providers = []): string
    {
        $a = (array) (($this->ctx['security'] ?? [])['login_appearance'] ?? []);
        // Legacy value: this used to be the string 'standalone'|'popup'|'embedded'.
        if (!is_array(($this->ctx['security'] ?? [])['login_appearance'] ?? null)) {
            $a = [];
        }
        $get = static fn(string $k, $d = '') => (($a[$k] ?? '') === '' || ($a[$k] ?? null) === null) ? $d : $a[$k];
        $bool = static fn(string $k, bool $d): bool => array_key_exists($k, $a) ? (bool) $a[$k] : $d;
        $esc = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');

        $layout   = in_array($get('layout', 'centered'), ['centered', 'split', 'cover'], true) ? $get('layout', 'centered') : 'centered';
        $bgStyle  = in_array($get('bgStyle', 'gradient'), ['solid', 'gradient', 'image'], true) ? $get('bgStyle', 'gradient') : 'gradient';
        $color1   = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $get('color1', '')) ? $get('color1') : '';
        $color2   = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $get('color2', '')) ? $get('color2') : '';
        $angle    = (int) $get('angle', 135);
        $image    = trim((string) $get('image', ''));
        $overlay  = max(0, min(100, (int) $get('overlay', 45)));
        $accent   = preg_match('/^#[0-9a-f]{3,8}$/i', (string) $get('accent', '')) ? $get('accent') : ($color1 !== '' ? $color1 : '');
        $accentCss = $accent !== '' ? $accent : 'var(--pp-primary,#4f46e5)';
        $radius   = max(0, min(40, (int) $get('radius', 16)));
        $width    = max(300, min(560, (int) $get('width', 400)));
        $shadow   = $bool('shadow', true);
        $glass    = $bool('glass', false);

        $title    = (string) $get('title', 'Welcome back');
        $subtitle = (string) $get('subtitle', 'Sign in to continue');
        $showLogo = $bool('showLogo', true);
        $logo     = trim((string) $get('logo', '')) ?: (string) ($appBrand['image'] ?? '');
        $brandName = (string) ($appBrand['name'] ?? 'App');

        $userLabel = (string) $get('usernameLabel', 'Username');
        $passLabel = (string) $get('passwordLabel', 'Password');
        $btnText   = (string) $get('buttonText', 'Sign in');
        $showRemember = $bool('showRemember', true);
        $showForgot   = $bool('showForgot', true);
        $showRegister = $bool('showRegister', true);
        $footer       = (string) $get('footer', '');

        // Background layer
        if ($bgStyle === 'image' && $image !== '') {
            $bg = "background-image:linear-gradient(rgba(2,6,23,{$overlay}%),rgba(2,6,23,{$overlay}%)),url('" . $esc($image) . "');background-size:cover;background-position:center";
        } elseif ($bgStyle === 'solid') {
            $bg = 'background:' . ($color1 !== '' ? $esc($color1) : 'var(--pp-app-bg,#0f172a)');
        } else {
            $c1 = $color1 !== '' ? $color1 : 'var(--pp-primary,#4f46e5)';
            $c2 = $color2 !== '' ? $color2 : 'var(--pp-accent,#0ea5e9)';
            $bg = "background:linear-gradient({$angle}deg," . $esc($c1) . ',' . $esc($c2) . ')';
        }

        $cardBg = $glass
            ? 'background:color-mix(in srgb,var(--pp-surface,#fff) 82%,transparent);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)'
            : 'background:var(--pp-surface,#fff)';
        $cardShadow = $shadow ? 'box-shadow:0 30px 70px -20px rgba(2,6,23,.45),0 8px 20px -12px rgba(2,6,23,.3)' : 'box-shadow:none';

        $markText = trim((string) $get('mark', '')) !== '' ? trim((string) $get('mark', '')) : mb_substr($brandName, 0, 2);
        $logoHtml = ($showLogo && $logo !== '')
            ? '<img class="pp-login-logo" src="' . $esc($logo) . '" alt="' . $esc($brandName) . '">'
            : ($showLogo ? '<div class="pp-login-mark">' . $esc($markText) . '</div>' : '');

        $footerHtml = $footer !== '' ? '<p class="pp-login-footer">' . $esc($footer) . '</p>' : '';

        $rememberHtml = $showRemember
            ? '<label class="pp-login-remember"><input type="checkbox" name="remember" value="1"><span>Remember me</span></label>'
            : '';
        $linksParts = [];
        if ($showForgot)   $linksParts[] = '<a href="<?= APP_BASE ?>/generated/auth/forgot-password.php">Forgot password?</a>';
        if ($showRegister) $linksParts[] = '<a href="<?= APP_BASE ?>/generated/auth/register.php">Create account</a>';
        $linksHtml = $linksParts !== [] ? '<p class="pp-login-links">' . implode(' <span>·</span> ', $linksParts) . '</p>' : '';

        // Social buttons reuse the providers already resolved for this build.
        $socialHtml = '';
        if ($providers !== []) {
            $btns = '';
            foreach ($providers as $p) {
                $pid = preg_replace('/[^a-z0-9_-]/', '', strtolower((string) $p));
                if ($pid === '') continue;
                $btns .= '<button class="pp-login-social" type="submit" name="auth_provider" value="' . $esc($pid) . '">'
                      .  '<i class="bi bi-' . $esc($pid) . '"></i><span>' . $esc(ucfirst($pid)) . '</span></button>';
            }
            if ($btns !== '') {
                $socialHtml = '<div class="pp-login-divider"><span>or</span></div><div class="pp-login-socials">' . $btns . '</div>';
            }
        }

        $splitAside = '';
        if ($layout === 'split') {
            $splitAside = '<aside class="pp-login-aside"><div class="pp-login-aside-in">'
                . $logoHtml
                . '<h2>' . $esc($title) . '</h2>'
                . ($subtitle !== '' ? '<p>' . $esc($subtitle) . '</p>' : '')
                . '</div></aside>';
        }
        // In split layout the brand block lives in the aside, not above the form.
        $headHtml = $layout === 'split'
            ? '<h1 class="pp-login-title">' . $esc($btnText) . '</h1>'
            : $logoHtml . '<h1 class="pp-login-title">' . $esc($title) . '</h1>'
              . ($subtitle !== '' ? '<p class="pp-login-sub">' . $esc($subtitle) . '</p>' : '');

        $titleEsc = $esc($brandName !== '' ? $brandName . ' — Sign in' : 'Sign in');
        $head = $this->authHeadTags();

        return <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\$error = \$error ?? '';
\$message = \$message ?? '';
$phpLogic
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$titleEsc</title>$head
<style>
.pp-login-page{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;$bg;font-family:var(--pp-font,'Inter',system-ui,sans-serif)}
.pp-login-page.is-split{padding:0;align-items:stretch}
.pp-login-shell{display:flex;width:100%;max-width:min(1040px,100%);margin:auto}
.pp-login-page.is-split .pp-login-shell{max-width:none;min-height:100vh}
.pp-login-aside{flex:1 1 50%;display:none;align-items:center;justify-content:center;padding:40px;color:#fff;$bg}
.pp-login-aside-in{max-width:380px;text-align:center}
.pp-login-aside h2{margin:14px 0 6px;font-size:26px;font-weight:800}
.pp-login-aside p{margin:0;opacity:.85;font-size:14px}
.pp-login-panel{flex:1 1 50%;display:flex;align-items:center;justify-content:center;padding:24px}
.pp-login-page.is-split .pp-login-panel{background:var(--pp-app-bg,#f8fafc)}
.pp-login-card{width:100%;max-width:{$width}px;padding:32px 30px;border-radius:{$radius}px;$cardBg;$cardShadow;border:1px solid var(--pp-border,rgba(148,163,184,.25))}
.pp-login-logo{max-height:52px;max-width:190px;object-fit:contain;margin:0 auto 14px;display:block}
.pp-login-mark{width:52px;height:52px;margin:0 auto 14px;display:grid;place-items:center;border-radius:14px;background:{$accentCss};color:#fff;font-weight:800;font-size:19px;text-transform:uppercase}
.pp-login-title{margin:0;text-align:center;font-size:23px;font-weight:800;color:var(--pp-text,#0f172a)}
.pp-login-sub{margin:6px 0 0;text-align:center;font-size:13.5px;color:var(--pp-text-muted,#64748b)}
.pp-login-form{margin-top:22px;display:grid;gap:14px}
.pp-login-field{display:grid;gap:6px}
.pp-login-field span{font-size:11px;font-weight:750;letter-spacing:.05em;text-transform:uppercase;color:var(--pp-text-muted,#64748b)}
.pp-login-field input{width:100%;padding:11px 13px;border:1px solid var(--pp-border,#dbe2ea);border-radius:10px;background:var(--pp-input-bg,#fff);color:var(--pp-text,#0f172a);font-size:14px}
.pp-login-field input:focus{outline:none;border-color:{$accentCss};box-shadow:0 0 0 3px color-mix(in srgb,{$accentCss} 18%,transparent)}
.pp-login-remember{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--pp-text-muted,#64748b);cursor:pointer}
.pp-login-btn{width:100%;padding:12px 16px;border:0;border-radius:10px;background:{$accentCss};color:#fff;font-size:14.5px;font-weight:750;cursor:pointer;transition:filter .15s ease,transform .15s ease}
.pp-login-btn:hover{filter:brightness(1.06)}
.pp-login-btn:active{transform:translateY(1px)}
.pp-login-links{margin:16px 0 0;text-align:center;font-size:13px}
.pp-login-links a{color:{$accentCss};text-decoration:none}
.pp-login-links a:hover{text-decoration:underline}
.pp-login-links span{color:var(--pp-text-muted,#94a3b8);margin:0 4px}
.pp-login-footer{margin:18px 0 0;text-align:center;font-size:11.5px;color:var(--pp-text-muted,#94a3b8)}
.pp-login-divider{position:relative;margin:18px 0;text-align:center}
.pp-login-divider::before{content:'';position:absolute;top:50%;left:0;right:0;height:1px;background:var(--pp-border,#e2e8f0)}
.pp-login-divider span{position:relative;padding:0 10px;background:var(--pp-surface,#fff);font-size:11px;color:var(--pp-text-muted,#94a3b8);text-transform:uppercase;letter-spacing:.06em}
.pp-login-socials{display:grid;gap:8px}
.pp-login-social{display:flex;align-items:center;justify-content:center;gap:8px;padding:10px;border:1px solid var(--pp-border,#dbe2ea);border-radius:10px;background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);font-size:13.5px;font-weight:650;cursor:pointer}
.pp-login-social:hover{background:var(--pp-surface-soft,#f8fafc)}
.pp-login-alert{margin-bottom:14px;padding:10px 12px;border-radius:9px;font-size:13px}
.pp-login-alert.is-error{background:color-mix(in srgb,var(--pp-danger,#dc2626) 12%,transparent);color:var(--pp-danger,#dc2626)}
.pp-login-alert.is-info{background:color-mix(in srgb,var(--pp-primary,#2563eb) 12%,transparent);color:var(--pp-primary,#2563eb)}
.pp-login-alert.is-hint{background:color-mix(in srgb,{$accentCss} 9%,transparent);border:1px dashed color-mix(in srgb,{$accentCss} 45%,var(--pp-border,#cbd5e1));color:var(--pp-text-muted,#475569);display:flex;align-items:flex-start;gap:7px;line-height:1.5}.pp-login-alert.is-hint i{margin-top:1px;flex:0 0 auto}.pp-login-alert.is-hint strong{color:{$accentCss};white-space:nowrap}
@media(max-width:719.98px){.pp-login-page.is-split .pp-login-panel{ $bg }}@media(min-width:720px){.pp-login-page.is-split .pp-login-aside{display:flex}}
@media(max-width:520px){.pp-login-card{padding:26px 20px}}
</style></head>
<body class="pp-login-page is-$layout">
<div class="pp-login-shell">
$splitAside
<div class="pp-login-panel">
<div class="pp-login-card">
$headHtml
<?= flashMessage() ?>
<?php if (\$error): ?><div class="pp-login-alert is-error"><?= htmlspecialchars(\$error) ?></div><?php endif; ?>
<?php if (\$message): ?><div class="pp-login-alert is-info"><?= htmlspecialchars(\$message) ?></div><?php endif; ?>
<?php if (!empty(\$__adminHint)): ?><div class="pp-login-alert is-hint"><?= \$__adminHint ?></div><?php endif; ?>
<form method="post" class="pp-login-form">
<?= csrfField() ?>
<label class="pp-login-field"><span>$userLabel</span><input name="username" autocomplete="username" required autofocus></label>
<label class="pp-login-field"><span>$passLabel</span><input name="password" type="password" autocomplete="current-password" required></label>
$rememberHtml
<button class="pp-login-btn" type="submit">$btnText</button>
$socialHtml
</form>
$linksHtml
$footerHtml
</div>
</div>
</div>
</body></html>
PHP;
    }

    /**
     * Put the bot-trap fields inside the first <form> of a generated page.
     *
     * Done as a post-processing step because the form markup comes from two
     * different renderers (LoginPageCompiler for a designed page, the built-in
     * fallback otherwise) and neither should have to know about the trap. The
     * emitted call is evaluated at request time, so it respects the project
     * setting and produces a fresh timestamp per visitor.
     */
    private function injectBotTrap(string $content): string
    {
        return preg_replace(
            '/(<form\b[^>]*>)/i',
            '$1<?= \\PpPhp\\Core\\Auth\\AuthGuard::botTrapFields() ?>',
            $content,
            1
        ) ?? $content;
    }

    /** Stylesheet/link tags shared by every generated auth page. */

    /**
     * The generated app's Two-Factor pages: a self-service setup/manage page
     * (shell-wrapped, behind auth) and the login challenge (standalone, shown
     * mid-login while a session is withheld). Both are themed via --pp-* tokens.
     */
    /**
     * The generated app's Two-Factor pages: a self-service setup/manage page
     * (shell-wrapped, behind auth) and the login challenge (standalone, shown
     * mid-login while a session is withheld). Both offer exactly the methods the
     * project enabled (authenticator and/or email) and are themed via --pp-*.
     */
    /**
     * The generated app's Two-Factor pages: a self-service setup/manage page
     * (behind auth) where a user turns on ONE of the admin-enabled methods, and
     * the login challenge (standalone) that verifies that one method. Themed via
     * --pp-* tokens.
     */
    /**
     * The generated app's Two-Factor pages: a self-service setup/manage page
     * (behind auth) where a user turns on ONE of the admin-enabled methods, and
     * the login challenge (standalone) that verifies that one method.
     *
     * Chrome (heading, subtitle, accent, method labels) is Designer-driven via
     * $opts (Designer → Common Pages → Two-Factor). Themed with --pp-* tokens and
     * fully responsive.
     */
    /**
     * The generated app's Two-Factor pages.
     *
     * two-factor.php — one self-service page: an elegant method chooser (the
     *   enabled methods as selectable options) that reveals the chosen method's
     *   setup card inline. Chrome (heading/subtitle/accent/labels) is Designer-driven.
     * two-factor-verify.php — the login challenge, rendered in one of four
     *   templates (Aurora / Halo / Executive / Verdant) that match the Login page,
     *   chosen in the Designer.
     */
    private function generateTwoFactorPages(string $authDir, array $opts = []): array
    {
        $written = [];

        $heading   = trim((string) ($opts['heading'] ?? '')) ?: 'Two-Factor Authentication';
        $subtitle  = trim((string) ($opts['subtitle'] ?? '')) ?: 'Add a second step to your sign-in. Pick one method — even if your password is stolen, nobody gets in without it.';
        $totpTitle = trim((string) ($opts['totpTitle'] ?? '')) ?: 'Authenticator app';
        $emailTitle = trim((string) ($opts['emailTitle'] ?? '')) ?: 'Email verification';
        $smsTitle  = trim((string) ($opts['smsTitle'] ?? '')) ?: 'SMS verification';
        $accentRaw = trim((string) ($opts['accent'] ?? ''));
        $accent    = preg_match('/^#[0-9a-fA-F]{3,8}$/', $accentRaw) ? $accentRaw : '';
        $accentStyle = $accent !== '' ? ' style="--pp2fa-accent:' . $accent . '"' : '';
        $tpl = strtolower((string) ($opts['challengeTemplate'] ?? 'aurora'));
        if (!in_array($tpl, ['aurora', 'flat', 'halo', 'executive', 'verdant'], true)) $tpl = 'aurora';
        // The challenge carries its own accent (from the chosen template); the
        // setup page stays theme-driven (its own accent is usually blank).
        $chAccentRaw = trim((string) ($opts['challengeAccent'] ?? $accentRaw));
        $chAccent = preg_match('/^#[0-9a-fA-F]{3,8}$/', $chAccentRaw) ? $chAccentRaw : '#6d5dfc';

        $tok = [
            '__2FA_HEADING_HTML__' => htmlspecialchars($heading, ENT_QUOTES),
            '__2FA_HEADING_PHP__'  => str_replace(['\\', "'"], ['\\\\', "\\'"], $heading),
            '__2FA_SUBTITLE__'     => htmlspecialchars($subtitle, ENT_QUOTES),
            '__2FA_TOTP_TITLE__'   => htmlspecialchars($totpTitle, ENT_QUOTES),
            '__2FA_EMAIL_TITLE__'  => htmlspecialchars($emailTitle, ENT_QUOTES),
            '__2FA_SMS_TITLE__'    => htmlspecialchars($smsTitle, ENT_QUOTES),
            '__2FA_ACCENT__'       => $accentStyle,
            '__2FA_CH_TPL__'       => $tpl,
            '__2FA_CH_ACCENT__'    => $chAccent,
        ];

        $head = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css">';

        // ── Setup-page CSS (chooser + reveal, themed --pp-*) ──
        $css = <<<'CSS'
<style>
:root{--pp-text-muted:var(--pp-muted,#64748b)}
.pp2fa{--pp2fa-accent:var(--pp-primary,#4f46e5);width:100%}
.pp2fa-lead{margin:-4px 0 20px;color:var(--pp-text-muted,#64748b);font-size:14px;line-height:1.6;max-width:72ch}
.pp2fa-alert{padding:12px 15px;border-radius:12px;font-size:13px;margin-bottom:16px;border:1px solid transparent;display:flex;gap:9px;align-items:flex-start}
.pp2fa-alert i{font-size:16px;margin-top:1px}
.pp2fa-alert.is-ok{color:var(--pp-success-text,#166534);background:var(--pp-success-soft,#f0fdf4);border-color:var(--pp-success-border,#bbf7d0)}
.pp2fa-alert.is-err{color:var(--pp-danger-text,#991b1b);background:var(--pp-danger-soft,#fef2f2);border-color:var(--pp-danger-border,#fecaca)}
.pp2fa-toggle{display:flex;align-items:center;gap:16px;padding:18px 20px;border:1px solid var(--pp-border,#e2e8f0);border-radius:18px;background:var(--pp-surface,#fff);box-shadow:0 1px 2px rgba(15,23,42,.04);transition:border-color .2s,box-shadow .2s}
.pp2fa-toggle.is-on{border-color:color-mix(in srgb,var(--pp2fa-accent) 40%,var(--pp-border,#e2e8f0));box-shadow:0 8px 26px color-mix(in srgb,var(--pp2fa-accent) 13%,transparent)}
.pp2fa-toggle-icon{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center;font-size:22px;color:var(--pp-text-muted,#64748b);background:var(--pp-surface-soft,#f1f5f9);transition:.2s}
.pp2fa-toggle.is-on .pp2fa-toggle-icon{color:#fff;background:var(--pp2fa-accent)}
.pp2fa-toggle-txt{flex:1;min-width:0}
.pp2fa-toggle-txt b{display:block;font-size:15.5px;font-weight:800;color:var(--pp-text,#0f172a)}
.pp2fa-toggle-txt small{display:block;margin-top:3px;font-size:12.5px;color:var(--pp-text-muted,#64748b)}
.pp2fa-switch{position:relative;display:inline-block;width:52px;height:30px;flex:0 0 auto;cursor:pointer}
.pp2fa-switch input{position:absolute;opacity:0;width:0;height:0}
.pp2fa-slider{position:absolute;inset:0;border-radius:999px;background:var(--pp-border-strong,#cbd5e1);transition:.2s}
.pp2fa-slider::before{content:"";position:absolute;left:3px;top:3px;width:24px;height:24px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:.2s}
.pp2fa-switch input:checked+.pp2fa-slider{background:var(--pp2fa-accent)}
.pp2fa-switch input:checked+.pp2fa-slider::before{transform:translateX(22px)}
.pp2fa-switch input:focus-visible+.pp2fa-slider{box-shadow:0 0 0 4px color-mix(in srgb,var(--pp2fa-accent) 25%,transparent)}
.pp2fa-reveal{margin-top:22px}
.pp2fa-reveal[hidden]{display:none}
.pp2fa-reveal:not([hidden]){display:grid;grid-template-columns:minmax(300px,400px) 1fr;gap:24px;align-items:start;animation:pp2faIn .25s ease}
@keyframes pp2faIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
.pp2fa-setups{min-width:0}
.pp2fa-sec-label{color:var(--pp-text-muted,#64748b);font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;margin:2px 0 13px}
.pp2fa-cards{display:flex;flex-direction:column;gap:11px}
.pp2fa-mcard{position:relative;text-align:left;display:flex;align-items:center;gap:14px;padding:15px 16px;border:1.5px solid var(--pp-border,#e2e8f0);border-radius:15px;background:var(--pp-surface,#fff);cursor:pointer;font:inherit;transition:border-color .16s,box-shadow .16s,transform .16s}
.pp2fa-mcard:hover{border-color:color-mix(in srgb,var(--pp2fa-accent) 42%,var(--pp-border,#e2e8f0));transform:translateY(-1px);box-shadow:0 10px 24px rgba(15,23,42,.08)}
.pp2fa-mcard-icon{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;font-size:20px;color:var(--pp2fa-accent);background:color-mix(in srgb,var(--pp2fa-accent) 12%,transparent);transition:.16s}
.pp2fa-mcard-body{flex:1;min-width:0}
.pp2fa-mcard-body b{display:block;font-size:14.5px;font-weight:800;color:var(--pp-text,#0f172a)}
.pp2fa-mcard-body span{display:block;font-size:12px;line-height:1.45;color:var(--pp-text-muted,#64748b);margin-top:2px}
.pp2fa-mcard-pill{flex:0 0 auto;align-self:flex-start;font-size:9.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:3px 8px;border-radius:999px;background:var(--pp-surface-soft,#f1f5f9);color:var(--pp-text-muted,#64748b)}
.pp2fa-mcard.is-sel{border-color:var(--pp2fa-accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--pp2fa-accent) 13%,transparent)}
.pp2fa-mcard.is-sel .pp2fa-mcard-icon{background:var(--pp2fa-accent);color:#fff}
.pp2fa-mcard.is-active{border-color:color-mix(in srgb,var(--pp-success,#16a34a) 55%,transparent)}
.pp2fa-mcard.is-active .pp2fa-mcard-pill{background:var(--pp-success,#16a34a);color:#fff}
.pp2fa-mcard.is-active.is-sel{box-shadow:0 0 0 4px color-mix(in srgb,var(--pp-success,#16a34a) 16%,transparent)}
.pp2fa-mcard.is-off{cursor:not-allowed;opacity:.5}
.pp2fa-mcard.is-off:hover{transform:none;box-shadow:none;border-color:var(--pp-border,#e2e8f0)}
.pp2fa-setup{border:1px solid var(--pp-border,#e2e8f0);border-radius:16px;background:var(--pp-surface-soft,#f8fafc);padding:22px;margin-bottom:8px}
.pp2fa-setup[hidden]{display:none}
.pp2fa-setup ol{margin:0 0 16px;padding-left:18px;color:var(--pp-text-muted,#64748b);font-size:12.5px;line-height:1.8}
.pp2fa-setup-lead{margin:0 0 14px;color:var(--pp-text-muted,#64748b);font-size:13px;line-height:1.6}
.pp2fa-onnote{display:flex;gap:9px;align-items:flex-start;font-size:13px;line-height:1.5;color:var(--pp-success-text,#166534);background:var(--pp-success-soft,#f0fdf4);border:1px solid var(--pp-success-border,#bbf7d0);border-radius:11px;padding:11px 14px;margin-bottom:14px}
.pp2fa-onnote i{font-size:15px;margin-top:1px}
.pp2fa-enroll{display:flex;gap:20px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
.pp2fa-qr{padding:10px;background:#fff;border:1px solid var(--pp-border,#e2e8f0);border-radius:14px;line-height:0;box-shadow:0 2px 8px rgba(15,23,42,.06)}
.pp2fa-qr canvas{display:block;border-radius:4px}
.pp2fa-manual{flex:1;min-width:170px;font-size:12px;color:var(--pp-text-muted,#64748b)}
.pp2fa-key{display:inline-block;margin-top:7px;padding:9px 12px;border-radius:10px;background:var(--pp-surface,#fff);border:1px dashed var(--pp-border,#cbd5e1);font-family:var(--pp-mono,monospace);font-size:14px;letter-spacing:2px;color:var(--pp-text,#0f172a);word-break:break-all}
.pp2fa-copy{margin-left:9px;border:1px solid var(--pp-border,#dbe2ea);background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border-radius:8px;padding:5px 10px;font-size:11.5px;font-weight:700;cursor:pointer}
.pp2fa-copy:hover{border-color:var(--pp2fa-accent);color:var(--pp2fa-accent)}
.pp2fa-otp{display:flex;flex-direction:column;gap:7px;max-width:280px}
.pp2fa-otp span{font-size:11px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--pp-text-muted,#64748b)}
.pp2fa-otp input{padding:12px 14px;border:1px solid var(--pp-input-border,#dbe2ea);border-radius:12px;background:var(--pp-input-bg,#fff);color:var(--pp-input-text,#0f172a);font-size:22px;letter-spacing:8px;text-indent:8px;text-align:center;font-family:var(--pp-mono,monospace)}
.pp2fa-otp input:focus{outline:none;border-color:var(--pp2fa-accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--pp2fa-accent) 22%,transparent)}
.pp2fa-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 18px;border:0;border-radius:11px;background:var(--pp2fa-accent);color:#fff;font-size:13.5px;font-weight:750;cursor:pointer;margin-top:12px;transition:filter .15s}
.pp2fa-btn:hover{filter:brightness(1.07)}
.pp2fa-btn.is-danger{background:transparent;color:var(--pp-danger,#dc2626);border:1px solid color-mix(in srgb,var(--pp-danger,#dc2626) 34%,transparent)}
.pp2fa-btn.is-ghost{background:var(--pp-surface-soft,#f1f5f9);color:var(--pp-text,#0f172a)}
.pp2fa-btn.sm{padding:8px 14px;font-size:12.5px;margin-top:0}
.pp2fa-card{border:1px solid var(--pp-border,#e2e8f0);border-radius:16px;background:var(--pp-surface,#fff);padding:20px;margin-bottom:20px}
.pp2fa-card-icon{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;font-size:19px;color:var(--pp2fa-accent);background:color-mix(in srgb,var(--pp2fa-accent) 12%,transparent);margin-bottom:12px}
.pp2fa-card h3{margin:0 0 5px;font-size:15px;font-weight:800;color:var(--pp-text,#0f172a)}
.pp2fa-card p{margin:0 0 12px;color:var(--pp-text-muted,#64748b);font-size:12.5px;line-height:1.6}
.pp2fa-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px;margin:10px 0 12px}
.pp2fa-codes code{padding:10px;text-align:center;border-radius:9px;background:var(--pp-surface-soft,#f1f5f9);border:1px solid var(--pp-border,#e2e8f0);font-family:var(--pp-mono,monospace);font-size:14px;letter-spacing:1px;color:var(--pp-text,#0f172a)}
.pp2fa-print{border:1px solid var(--pp-border,#dbe2ea);background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border-radius:9px;padding:7px 13px;font-size:12px;font-weight:700;cursor:pointer}
.pp2fa-unavail{display:flex;gap:9px;align-items:center;font-size:12.5px;color:var(--pp-text-muted,#64748b)}
@media(max-width:900px){.pp2fa-reveal:not([hidden]){grid-template-columns:1fr}.pp2fa-setups{margin-top:2px}}
@media(max-width:560px){.pp2fa-enroll{gap:14px}.pp2fa-toggle{padding:16px}}
</style>
CSS;

        // ── Challenge-page CSS: the four Login looks + the code form ──
        $authCss = <<<'AUTHCSS'
<style>
html,body{margin:0;min-height:100%;font-family:Inter,system-ui,sans-serif}
.pp2fa-auth{--auth-accent:var(--pp-primary,__2FA_CH_ACCENT__);min-height:100vh;display:grid;grid-template-columns:1.1fr minmax(400px,.9fr);background:#11152d;color:#111827}
.pp2fa-auth.is-flat{grid-template-columns:1fr;place-items:center;background:var(--pp-bg,#fafafa)}
.pp2fa-auth.is-flat>.pp2fa-show{display:none}
.pp2fa-auth.is-flat .pp2fa-zone{width:100%;min-height:100vh;box-sizing:border-box;place-items:center;background:transparent}
.pp2fa-auth.is-flat .pp2fa-form{color:var(--pp-text,#0a0a0a);border:1px solid var(--pp-border,#e4e4e7);border-radius:var(--pp-radius,10px);background:var(--pp-surface,#fff);box-shadow:0 1px 2px rgba(0,0,0,.04),0 18px 44px -20px color-mix(in srgb,var(--pp-text,#0a0a0a) 22%,transparent)}
.pp2fa-auth.is-flat .pp2fa-form>.pp2fa-brand{display:flex;color:var(--pp-text)}
.pp2fa-auth.is-flat .pp2fa-form h2{color:var(--pp-text)}
.pp2fa-auth.is-flat .pp2fa-fsub{color:var(--pp-muted,#71717a)}
.pp2fa-auth.is-flat .pp2fa-field{color:var(--pp-muted,#71717a)}
.pp2fa-auth.is-flat .pp2fa-field input{background:var(--pp-surface,#fff);border:1px solid var(--pp-border,#e4e4e7);color:var(--pp-text,#0a0a0a)}
.pp2fa-auth.is-flat .pp2fa-field input::placeholder{color:var(--pp-muted,#71717a)}
.pp2fa-auth.is-flat .pp2fa-alt{border-top-color:var(--pp-border,#e4e4e7)}
.pp2fa-auth.is-flat .pp2fa-alt a,.pp2fa-auth.is-flat .pp2fa-alt button{color:var(--pp-primary,#2563eb)}
.pp2fa-show{padding:6vw;display:flex;flex-direction:column;justify-content:space-between;color:#fff;background:radial-gradient(circle at 15% 20%,#312b6a,transparent 42%)}
.pp2fa-show h1{max-width:600px;font-size:clamp(34px,4vw,58px);line-height:1.02;letter-spacing:-.05em;margin:0}
.pp2fa-show p{max-width:480px;color:#c9cee4;line-height:1.7;font-size:15px}
.pp2fa-brand{display:flex;align-items:center;gap:12px}
.pp2fa-brand b{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:var(--auth-accent);color:#fff;font-weight:800;font-size:18px}
.pp2fa-brand strong{display:block;font-size:16px}.pp2fa-brand small{display:block;margin-top:2px;opacity:.6;font-size:11px}
.pp2fa-zone{display:grid;place-items:center;padding:40px;background:linear-gradient(160deg,color-mix(in srgb,var(--auth-accent) 15%,#eceffa),color-mix(in srgb,var(--auth-accent) 6%,#eef1f8) 72%)}
.pp2fa-form{width:min(100%,400px);box-sizing:border-box;padding:40px;border:1px solid #e5e7eb;border-radius:24px;background:#fff;box-shadow:0 30px 80px #0f172a1f}
.pp2fa-form>.pp2fa-brand{display:none;margin-bottom:26px}
.pp2fa-fmark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;background:color-mix(in srgb,var(--auth-accent) 14%,transparent);color:var(--auth-accent);font-size:25px;margin-bottom:16px}
.pp2fa-form h2{margin:0;font-size:26px;letter-spacing:-.03em;color:#0f172a}
.pp2fa-fsub{margin:8px 0 22px;color:#7a8499;font-size:13.5px;line-height:1.5}
.pp2fa-field{display:grid;gap:7px;margin:0 0 4px;font-size:11px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:#7a8499}
.pp2fa-field input{height:52px;box-sizing:border-box;border:1px solid #dfe3ec;border-radius:13px;padding:0 16px;font-family:ui-monospace,monospace;font-size:22px;letter-spacing:8px;text-indent:8px;text-align:center;color:#0f172a}
.pp2fa-field input::placeholder{color:#c3cad6;letter-spacing:8px}
.pp2fa-field input:focus{outline:none;border-color:var(--auth-accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--auth-accent) 22%,transparent)}
.pp2fa-act{width:100%;height:50px;margin-top:16px;border:0;border-radius:13px;background:var(--auth-accent);color:#fff;font-weight:800;font-size:14.5px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}
.pp2fa-act:hover{filter:brightness(1.06)}
.pp2fa-note{padding:11px 14px;border-radius:11px;font-size:12.5px;margin-bottom:14px;display:flex;gap:8px;align-items:flex-start}
.pp2fa-note.is-ok{color:#166534;background:#f0fdf4;border:1px solid #bbf7d0}
.pp2fa-note.is-err{color:#991b1b;background:#fef2f2;border:1px solid #fecaca}
.pp2fa-alt{margin-top:20px;padding-top:16px;border-top:1px solid #eef1f6;display:flex;flex-direction:column;gap:9px;font-size:12.5px}
.pp2fa-alt a,.pp2fa-alt button{background:none;border:0;padding:0;color:var(--auth-accent);font-weight:700;cursor:pointer;text-align:left;font-size:12.5px;text-decoration:none;display:inline-flex;gap:8px;align-items:center}
.pp2fa-alt a:hover,.pp2fa-alt button:hover{text-decoration:underline}
.pp2fa-alt .pp2fa-quit{color:#94a3b8}
.pp2fa-auth.is-halo{grid-template-columns:1fr;background:radial-gradient(circle at 15% 20%,#8b5cf6,transparent 35%),radial-gradient(circle at 85% 80%,#2563eb,transparent 35%),#100b2d}
.pp2fa-auth.is-halo>.pp2fa-show{position:absolute;background:none;pointer-events:none}
.pp2fa-auth.is-halo .pp2fa-show>*:not(.pp2fa-brand),.pp2fa-auth.is-halo .pp2fa-show .pp2fa-brand{display:none}
.pp2fa-auth.is-halo .pp2fa-zone{z-index:2;background:transparent}
.pp2fa-auth.is-halo .pp2fa-form{background:#fffffff2;backdrop-filter:blur(24px)}
.pp2fa-auth.is-halo .pp2fa-form>.pp2fa-brand,.pp2fa-auth.is-executive .pp2fa-form>.pp2fa-brand{display:flex}
.pp2fa-auth.is-executive{grid-template-columns:.85fr 1.15fr;background:#eef3f1}
.pp2fa-auth.is-executive .pp2fa-show{color:#17332e;background:linear-gradient(145deg,#e2ece8,#f3f7f5)}
.pp2fa-auth.is-executive .pp2fa-show p{color:#5c746e}
.pp2fa-auth.is-executive .pp2fa-zone{background:#fff}
.pp2fa-auth.is-executive .pp2fa-form{border:0;box-shadow:none}
.pp2fa-auth.is-verdant{position:relative;overflow:hidden;grid-template-columns:1fr;place-items:center;background:radial-gradient(105% 58% at -2% 79%,#254b31 0 39%,transparent 39.5%),radial-gradient(98% 58% at 104% 73%,#78a54b 0 41%,transparent 41.5%),linear-gradient(180deg,#77989a 0,#b8ad87 38%,#d9bd7c 47%,#86a957 62%,#294d31 100%)}
.pp2fa-auth.is-verdant>.pp2fa-show{position:absolute;inset:0;z-index:1;padding:0;pointer-events:none}
.pp2fa-auth.is-verdant .pp2fa-show>*{display:none}
.pp2fa-auth.is-verdant .pp2fa-zone{position:relative;z-index:2;width:100%;min-height:100vh;box-sizing:border-box;background:transparent}
.pp2fa-auth.is-verdant .pp2fa-form{color:#fff;border:1.5px solid #ffffffb3;border-radius:28px;background:linear-gradient(145deg,#ffffff33,#0c231f70);box-shadow:0 28px 80px #10271e66,inset 0 1px 0 #ffffff45;backdrop-filter:blur(24px) saturate(125%)}
.pp2fa-auth.is-verdant .pp2fa-form>.pp2fa-brand{display:none}
.pp2fa-auth.is-verdant .pp2fa-form h2{color:#fff}.pp2fa-auth.is-verdant .pp2fa-fsub,.pp2fa-auth.is-verdant .pp2fa-field{color:#f5fff7d9}
.pp2fa-auth.is-verdant .pp2fa-field input{border-color:#ffffffa6;background:#0f2a1f3b;color:#fff}
.pp2fa-auth.is-verdant .pp2fa-act{background:linear-gradient(100deg,#bfd600,#22bc67)}
.pp2fa-auth.is-verdant .pp2fa-alt{border-top-color:#ffffff33}.pp2fa-auth.is-verdant .pp2fa-alt a,.pp2fa-auth.is-verdant .pp2fa-alt button{color:#fff}
@media(max-width:820px){.pp2fa-auth{display:block}.pp2fa-show{display:none}.pp2fa-zone{min-height:100vh;box-sizing:border-box;padding:22px}.pp2fa-form{padding:30px 24px}.pp2fa-form>.pp2fa-brand{display:flex}.pp2fa-auth.is-verdant .pp2fa-zone{background:transparent}.pp2fa-auth.is-verdant .pp2fa-form>.pp2fa-brand{display:none}}
</style>
AUTHCSS;

        // ── two-factor.php (setup / manage — chooser + reveal) ──
        $setup = <<<'SETUP'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
if (!PP_2FA_ENABLED) { header('Location: ' . APP_BASE . '/'); exit; }
$T = '\PpPhp\Core\Auth\TwoFactor';
$user = \PpPhp\Core\Security::CurrentUserData();
$uid  = (int) ($user['id'] ?? 0);
$active = $T::activeMethod($uid);
$emailAddr = PP_2FA_EMAIL ? $T::contact($uid, PP_2FA_EMAIL_FIELD) : '';
$phone     = PP_2FA_SMS ? $T::contact($uid, PP_2FA_PHONE_FIELD) : '';
$error = ''; $notice = ''; $backupCodes = [];
$csrf = \PpPhp\Core\Security::CSRF()->Token();
$mask = static function (string $s, bool $isEmail): string {
    if ($isEmail) { $at = strpos($s, '@'); if ($at === false) return $s; return substr($s, 0, min(2, $at)) . str_repeat('*', 3) . substr($s, $at); }
    return strlen($s) > 4 ? str_repeat('*', max(0, strlen($s) - 4)) . substr($s, -4) : $s;
};

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!\PpPhp\Core\Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
        $error = 'Your session expired. Please try again.';
    } else {
        $action = (string) ($_POST['action'] ?? '');
        $code = (string) ($_POST['code'] ?? '');
        if ($action === 'totp-enable' && PP_2FA_TOTP) {
            $secret = (string) ($_SESSION['pp_2fa_setup_secret'] ?? '');
            if ($secret === '') { $error = 'Setup timed out. Please restart.'; }
            elseif (!$T::verifySecret($secret, $code)) { $error = 'That code did not match. Check your authenticator app and try again.'; }
            else { $backupCodes = $T::enableTotp($uid, $secret); unset($_SESSION['pp_2fa_setup_secret']); $notice = 'Authenticator app is now your 2FA method.' . ($backupCodes ? ' Save your backup codes below.' : ''); }
        } elseif ($action === 'email-enable' && PP_2FA_EMAIL) {
            if ($emailAddr === '') { $error = 'No email address is on file for your account.'; }
            else { $backupCodes = $T::enableEmail($uid); $notice = 'Email verification is now your 2FA method.' . ($backupCodes ? ' Save your backup codes below.' : ''); }
        } elseif ($action === 'sms-enable' && PP_2FA_SMS) {
            if ($phone === '') { $error = 'No phone number is on file for your account.'; }
            else { $backupCodes = $T::enableSms($uid); $notice = 'SMS verification is now your 2FA method.' . ($backupCodes ? ' Save your backup codes below.' : ''); }
        } elseif ($action === 'disable') {
            $T::disable($uid); $notice = 'Two-factor authentication has been turned off.';
        } elseif ($action === 'regen' && $active !== '') {
            $backupCodes = $T::regenerateBackupCodes($uid); $notice = 'New backup codes generated. Your previous codes no longer work.';
        }
        $active = $T::activeMethod($uid);
    }
}

$secret = ''; $otpauth = ''; $prettyKey = '';
if (PP_2FA_TOTP && $active !== 'totp') {
    $secret = (string) ($_SESSION['pp_2fa_setup_secret'] ?? '');
    if ($secret === '') { $secret = $T::newSecret(); $_SESSION['pp_2fa_setup_secret'] = $secret; }
    $otpauth = $T::otpauthUri((string) ($user['username'] ?? 'user'), $secret, (string) ($GLOBALS['PP_APP_NAME'] ?? 'App'));
    $prettyKey = trim(chunk_split($secret, 4, ' '));
}
$labels = ['totp' => '__2FA_TOTP_TITLE__', 'email' => '__2FA_EMAIL_TITLE__', 'sms' => '__2FA_SMS_TITLE__'];
$backupCount = $active !== '' ? $T::backupCount($uid) : 0;
$armed = ($active !== '');

// The methods the admin switched on, each with its availability for this user.
$methods = [];
if (PP_2FA_TOTP)  $methods['totp']  = ['icon' => 'bi-phone',     'desc' => 'Six-digit codes from an authenticator app on your phone. Works offline.', 'avail' => true, 'reason' => ''];
if (PP_2FA_EMAIL) $methods['email'] = ['icon' => 'bi-envelope',  'desc' => $emailAddr !== '' ? ('A fresh code is emailed to ' . $mask($emailAddr, true) . ' at each sign-in.') : 'A fresh code is emailed to you at each sign-in.', 'avail' => $emailAddr !== '', 'reason' => 'Add an email address to your account to use this method.'];
if (PP_2FA_SMS)   $methods['sms']   = ['icon' => 'bi-chat-dots', 'desc' => $phone !== ''     ? ('A fresh code is texted to ' . $mask($phone, false) . ' at each sign-in.') : 'A fresh code is texted to your phone at each sign-in.', 'avail' => $phone !== '', 'reason' => 'Add a phone number to your account to use this method.'];

// Which card's setup panel is open by default: the active one, else the first usable one.
$selDefault = $active !== '' ? $active : '';
if ($selDefault === '') { foreach ($methods as $mk => $mv) { if ($mv['avail']) { $selDefault = $mk; break; } } }
if ($selDefault === '' && $methods) { $selDefault = array_key_first($methods); }
?>__HEAD__<title>__2FA_HEADING_HTML__</title></head>
<body>
<?php pp_shell_start('__2FA_HEADING_PHP__', '', ''); ?>
<div class="pp2fa"__2FA_ACCENT__ data-active="<?= htmlspecialchars($active) ?>">
    <p class="pp2fa-lead">__2FA_SUBTITLE__</p>
    <?php if ($notice !== ''): ?><div class="pp2fa-alert is-ok"><i class="bi bi-check-circle-fill"></i> <span><?= htmlspecialchars($notice) ?></span></div><?php endif; ?>
    <?php if ($error !== ''): ?><div class="pp2fa-alert is-err"><i class="bi bi-exclamation-triangle-fill"></i> <span><?= htmlspecialchars($error) ?></span></div><?php endif; ?>

    <?php if ($backupCodes): ?>
    <div class="pp2fa-card">
        <div class="pp2fa-card-icon"><i class="bi bi-key"></i></div>
        <h3>Your backup codes</h3>
        <p>Keep these somewhere safe. Each works <strong>once</strong> if you ever lose access to your method.</p>
        <div class="pp2fa-codes"><?php foreach ($backupCodes as $c) echo '<code>' . htmlspecialchars($c) . '</code>'; ?></div>
        <button type="button" class="pp2fa-print" onclick="window.print()"><i class="bi bi-printer"></i> Print codes</button>
    </div>
    <?php endif; ?>

    <!-- master on/off switch -->
    <div class="pp2fa-toggle<?= $armed ? ' is-on' : '' ?>" id="pp2faToggle">
        <div class="pp2fa-toggle-icon"><i class="bi <?= $armed ? 'bi-shield-fill-check' : 'bi-shield-slash' ?>" id="pp2faToggleIcon"></i></div>
        <div class="pp2fa-toggle-txt">
            <b>Two-factor authentication</b>
            <small id="pp2faToggleStatus"><?= $armed ? 'On &middot; ' . htmlspecialchars($labels[$active] ?? $active) : 'Off &middot; your account is protected by your password only' ?></small>
        </div>
        <label class="pp2fa-switch">
            <input type="checkbox" id="pp2faMaster"<?= $armed ? ' checked' : '' ?> aria-label="Enable two-factor authentication"<?= $methods ? '' : ' disabled' ?>>
            <span class="pp2fa-slider"></span>
        </label>
    </div>

    <?php if (!$methods): ?>
    <div class="pp2fa-alert is-err" style="margin-top:18px"><i class="bi bi-info-circle-fill"></i> <span>No verification methods are enabled for this app yet. An administrator can turn them on in Security settings.</span></div>
    <?php endif; ?>

    <form method="post" id="pp2faDisableForm" hidden><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="disable"></form>

    <div class="pp2fa-reveal" id="pp2faReveal"<?= $armed ? '' : ' hidden' ?>>
        <div class="pp2fa-choose">
        <p class="pp2fa-sec-label">Choose how you verify</p>
        <div class="pp2fa-cards">
            <?php foreach ($methods as $mk => $mv):
                $isActive = ($mk === $active);
                $isSel = ($mk === $selDefault);
                $isOff = (!$mv['avail'] && !$isActive);
            ?>
            <button type="button" class="pp2fa-mcard<?= $isActive ? ' is-active' : '' ?><?= $isSel ? ' is-sel' : '' ?><?= $isOff ? ' is-off' : '' ?>" data-card="<?= $mk ?>"<?= $isOff ? ' disabled' : '' ?>>
                <span class="pp2fa-mcard-icon"><i class="bi <?= $mv['icon'] ?>"></i></span>
                <span class="pp2fa-mcard-body"><b><?= htmlspecialchars($labels[$mk]) ?></b><span><?= htmlspecialchars($mv['desc']) ?></span></span>
                <span class="pp2fa-mcard-pill"><?= $isActive ? 'Active' : ($mv['avail'] ? 'Available' : 'Unavailable') ?></span>
            </button>
            <?php endforeach; ?>
        </div>
        </div>

        <div class="pp2fa-setups">
        <?php foreach ($methods as $mk => $mv): ?>
        <div class="pp2fa-setup" data-setup="<?= $mk ?>"<?= $mk === $selDefault ? '' : ' hidden' ?>>
            <?php if ($mk === 'totp'): ?>
                <?php if ($active === 'totp'): ?>
                <div class="pp2fa-onnote"><i class="bi bi-check-circle-fill"></i> <span>Authenticator app is your active method — <?= (int) $backupCount ?> backup code<?= $backupCount === 1 ? '' : 's' ?> remaining.</span></div>
                <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="regen"><button class="pp2fa-btn is-ghost" type="submit"><i class="bi bi-arrow-repeat"></i> Generate new backup codes</button></form>
                <?php else: ?>
                <ol><li>Install Google Authenticator, Authy, Microsoft Authenticator or 1Password.</li><li>Scan the QR code below — or type the key in by hand.</li><li>Enter the 6-digit code your app shows to confirm.</li></ol>
                <div class="pp2fa-enroll">
                    <div class="pp2fa-qr" id="pp2faQr"></div>
                    <div class="pp2fa-manual">Can&rsquo;t scan? Enter this key:<br><span class="pp2fa-key" id="pp2faKey"><?= htmlspecialchars($prettyKey) ?></span><button type="button" class="pp2fa-copy" data-key="<?= htmlspecialchars($secret) ?>">Copy</button></div>
                </div>
                <form method="post" autocomplete="off"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="totp-enable">
                    <label class="pp2fa-otp"><span>6-digit code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" placeholder="000000" required></label>
                    <button class="pp2fa-btn" type="submit"><i class="bi bi-shield-check"></i> <?= $active === '' ? 'Turn on authenticator' : 'Switch to authenticator' ?></button>
                </form>
                <?php endif; ?>
            <?php elseif ($mk === 'email'): ?>
                <?php if ($active === 'email'): ?>
                <div class="pp2fa-onnote"><i class="bi bi-check-circle-fill"></i> <span>Email verification is your active method — <?= (int) $backupCount ?> backup code<?= $backupCount === 1 ? '' : 's' ?> remaining.</span></div>
                <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="regen"><button class="pp2fa-btn is-ghost" type="submit"><i class="bi bi-arrow-repeat"></i> Generate new backup codes</button></form>
                <?php elseif ($emailAddr === ''): ?>
                <div class="pp2fa-unavail"><i class="bi bi-info-circle"></i> <?= htmlspecialchars($mv['reason']) ?></div>
                <?php else: ?>
                <p class="pp2fa-setup-lead">We&rsquo;ll email a 6-digit code to <strong><?= htmlspecialchars($mask($emailAddr, true)) ?></strong> every time you sign in. No app required.</p>
                <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="email-enable"><button class="pp2fa-btn" type="submit"><i class="bi bi-envelope-check"></i> <?= $active === '' ? 'Turn on email codes' : 'Switch to email codes' ?></button></form>
                <?php endif; ?>
            <?php else: ?>
                <?php if ($active === 'sms'): ?>
                <div class="pp2fa-onnote"><i class="bi bi-check-circle-fill"></i> <span>SMS verification is your active method — <?= (int) $backupCount ?> backup code<?= $backupCount === 1 ? '' : 's' ?> remaining.</span></div>
                <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="regen"><button class="pp2fa-btn is-ghost" type="submit"><i class="bi bi-arrow-repeat"></i> Generate new backup codes</button></form>
                <?php elseif ($phone === ''): ?>
                <div class="pp2fa-unavail"><i class="bi bi-info-circle"></i> <?= htmlspecialchars($mv['reason']) ?></div>
                <?php else: ?>
                <p class="pp2fa-setup-lead">We&rsquo;ll text a 6-digit code to <strong><?= htmlspecialchars($mask($phone, false)) ?></strong> every time you sign in.</p>
                <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="sms-enable"><button class="pp2fa-btn" type="submit"><i class="bi bi-chat-dots"></i> <?= $active === '' ? 'Turn on SMS codes' : 'Switch to SMS codes' ?></button></form>
                <?php endif; ?>
            <?php endif; ?>
        </div>
        <?php endforeach; ?>
        </div>
    </div>
</div>
__CSS__
<?php pp_shell_end(); ?>
<script src="<?= APP_BASE ?>/assets/js/pp-qr.js"></script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<script>
(function(){
    var otpauth = <?= json_encode($otpauth) ?>, qrDone = false;
    var wrap = document.querySelector('.pp2fa');
    var hasActive = !!(wrap && wrap.getAttribute('data-active'));
    function renderQr(){ if(qrDone||!otpauth)return; var el=document.getElementById('pp2faQr'); if(el&&window.PPQR){try{PPQR.render(el,otpauth,{scale:5});qrDone=true;}catch(e){}} }
    function selectCard(m){
        document.querySelectorAll('.pp2fa-mcard').forEach(function(c){ c.classList.toggle('is-sel', c.getAttribute('data-card')===m); });
        document.querySelectorAll('.pp2fa-setup').forEach(function(p){ p.hidden = p.getAttribute('data-setup')!==m; });
        if(m==='totp') renderQr();
    }
    document.querySelectorAll('.pp2fa-mcard').forEach(function(c){ c.addEventListener('click', function(){ if(c.disabled)return; selectCard(c.getAttribute('data-card')); }); });
    var selCard=document.querySelector('.pp2fa-mcard.is-sel'); if(selCard&&selCard.getAttribute('data-card')==='totp') renderQr();

    var master=document.getElementById('pp2faMaster'), reveal=document.getElementById('pp2faReveal'),
        toggle=document.getElementById('pp2faToggle'), statusEl=document.getElementById('pp2faToggleStatus'),
        ticon=document.getElementById('pp2faToggleIcon');
    if(master){
        master.addEventListener('change', function(){
            if(master.checked){
                if(reveal) reveal.hidden=false;
                if(toggle) toggle.classList.add('is-on');
                if(ticon) ticon.className='bi bi-shield-fill-check';
                if(statusEl && !hasActive) statusEl.innerHTML='On &middot; choose a method below to finish';
                var sc=document.querySelector('.pp2fa-mcard.is-sel'); if(sc&&sc.getAttribute('data-card')==='totp') renderQr();
            } else if(hasActive){
                if(confirm('Turn off two-factor authentication? Your account will then be protected by your password only.')){
                    document.getElementById('pp2faDisableForm').submit();
                } else { master.checked=true; }
            } else {
                if(reveal) reveal.hidden=true;
                if(toggle) toggle.classList.remove('is-on');
                if(ticon) ticon.className='bi bi-shield-slash';
                if(statusEl) statusEl.innerHTML='Off &middot; your account is protected by your password only';
            }
        });
    }
    var cp=document.querySelector('.pp2fa-copy'); if(cp)cp.addEventListener('click',function(){var k=cp.getAttribute('data-key');(navigator.clipboard?navigator.clipboard.writeText(k):Promise.reject()).then(function(){cp.textContent='Copied';setTimeout(function(){cp.textContent='Copy';},1500);}).catch(function(){});});
})();
</script>
</body></html>
SETUP;

        // ── two-factor-verify.php (login challenge — templated) ──
        $verify = <<<'VERIFY'
<?php
require_once __DIR__ . '/../../_runtime.php';
$T = '\PpPhp\Core\Auth\TwoFactor';
$pending = $T::pending();
if ($pending === null) { header('Location: ' . APP_BASE . '/generated/auth/login.php'); exit; }
$uid = (int) ($pending['uid'] ?? 0);
$username = (string) ($pending['username'] ?? '');
$remember = !empty($pending['remember']);
$method = $T::activeMethod($uid);
if ($method === '') { $T::clearPending(); \PpPhp\Core\Security::LoginAs($username); header('Location: ' . APP_BASE . '/'); exit; }
$error = ''; $notice = ''; $view = 'code';
$csrf = \PpPhp\Core\Security::CSRF()->Token();
$appName = (string) ($GLOBALS['PP_APP_NAME'] ?? 'App');
$mask = static function (string $s, bool $isEmail): string {
    if ($isEmail) { $at = strpos($s, '@'); if ($at === false) return $s; return substr($s, 0, min(2, $at)) . str_repeat('*', 3) . substr($s, $at); }
    return strlen($s) > 4 ? str_repeat('*', max(0, strlen($s) - 4)) . substr($s, -4) : $s;
};
$contact = $method === 'email' ? $T::contact($uid, PP_2FA_EMAIL_FIELD) : ($method === 'sms' ? $T::contact($uid, PP_2FA_PHONE_FIELD) : '');
$channelSent = !empty($_SESSION['pp_2fa_otp']);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $view = in_array(($_POST['view'] ?? ''), ['code', 'send', 'backup'], true) ? $_POST['view'] : 'code';
    if (!\PpPhp\Core\Security::CSRF()->Check((string) ($_POST['_token'] ?? ''))) {
        $error = 'Your session expired. Please sign in again.';
    } elseif (($_POST['do'] ?? '') === 'send') {
        if ($method === 'totp') { $view = 'code'; }
        elseif ($contact !== '' && $T::sendOtp($uid, $method, $contact, $appName)) {
            $view = 'code'; $channelSent = true;
            $notice = ($method === 'sms' ? 'We texted a code to ' : 'We emailed a code to ') . htmlspecialchars($mask($contact, $method === 'email')) . '.';
        } else { $error = 'Could not send your code right now. Try a backup code instead.'; $view = 'backup'; }
    } elseif (($_POST['do'] ?? '') === 'show-backup') {
        $view = 'backup';
    } else {
        $code = (string) ($_POST['code'] ?? '');
        $ok = ($view === 'backup') ? $T::consumeBackupCode($uid, $code)
            : ($method === 'totp' ? $T::verifyTotp($uid, $code) : $T::verifyOtp($uid, $code));
        if ($ok) {
            $T::clearPending();
            \PpPhp\Core\Security::LoginAs($username);
            \PpPhp\Core\Auth\AuditLog::log('login', null, null, null, null, 'user: ' . $username . ' (2FA)');
            \PpPhp\Core\Auth\SessionGuard::track();
            if ($remember && $uid > 0) \PpPhp\Core\Security::SetRememberMeCookie($uid);
            $_SESSION['_flash'] = ['message' => 'Welcome back, ' . htmlspecialchars($username) . '!', 'type' => 'success'];
            $__dest = \PpPhp\Core\Events::fire('global.auth.afterLogin', ['user' => \PpPhp\Core\Security::CurrentUserData()]);
            header('Location: ' . ((is_string($__dest) && $__dest !== '') ? $__dest : APP_BASE . '/'));
            exit;
        }
        $error = $view === 'backup' ? 'That backup code is not valid (or already used).' : 'That code is not right. Please try again.';
    }
}
if ($view === 'code' && $method !== 'totp' && !$channelSent && $error === '') $view = 'send';

$titles = ['totp' => 'Enter your authenticator code', 'email' => 'Enter the emailed code', 'sms' => 'Enter the texted code'];
$title = $view === 'backup' ? 'Enter a backup code' : ($titles[$method] ?? 'Two-step verification');
$subs = ['totp' => 'Open your authenticator app and type the current 6-digit code.', 'email' => 'We sent a 6-digit code to ' . $mask($contact, true) . '.', 'sms' => 'We sent a 6-digit code to ' . $mask($contact, false) . '.'];
$sub = $view === 'backup' ? 'Enter one of the one-time backup codes you saved.' : ($view === 'send' ? ('Send a one-time code to ' . $mask($contact, $method === 'email') . '.') : ($subs[$method] ?? ''));
$initial = strtoupper(substr($appName, 0, 1)) ?: 'A';
$brand = '<div class="pp2fa-brand"><b>' . htmlspecialchars($initial) . '</b><div><strong>' . htmlspecialchars($appName) . '</strong><small>Secure sign-in</small></div></div>';
?>__HEAD__<title>Two-step verification</title>__AUTHCSS__</head>
<body>
<div class="pp2fa-auth is-__2FA_CH_TPL__">
    <aside class="pp2fa-show">
        <?= $brand ?>
        <div><h1>One more step to keep your account safe.</h1><p>Enter the code from your <?= $method === 'totp' ? 'authenticator app' : ($method === 'sms' ? 'phone' : 'inbox') ?> to finish signing in.</p></div>
        <div style="opacity:.55;font-size:12px">Protected by two-factor authentication</div>
    </aside>
    <main class="pp2fa-zone">
        <div class="pp2fa-form">
            <?= $brand ?>
            <div class="pp2fa-fmark"><i class="bi bi-shield-lock"></i></div>
            <h2><?= htmlspecialchars($title) ?></h2>
            <p class="pp2fa-fsub"><?= htmlspecialchars($sub) ?></p>
            <?php if ($notice !== ''): ?><div class="pp2fa-note is-ok"><i class="bi bi-check-circle-fill"></i> <span><?= $notice ?></span></div><?php endif; ?>
            <?php if ($error !== ''): ?><div class="pp2fa-note is-err"><i class="bi bi-exclamation-triangle-fill"></i> <span><?= htmlspecialchars($error) ?></span></div><?php endif; ?>
            <?php if ($view === 'send'): ?>
            <form method="post"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="do" value="send">
                <button class="pp2fa-act" type="submit"><i class="bi <?= $method === 'sms' ? 'bi-chat-dots' : 'bi-envelope' ?>"></i> Send my code</button></form>
            <?php else: ?>
            <form method="post" autocomplete="off">
                <input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="view" value="<?= htmlspecialchars($view) ?>">
                <label class="pp2fa-field"><span><?= $view === 'backup' ? 'Backup code' : '6-digit code' ?></span>
                    <input name="code" <?= $view === 'backup' ? 'maxlength="9"' : 'inputmode="numeric" pattern="[0-9]*" maxlength="6"' ?> autocomplete="one-time-code" placeholder="<?= $view === 'backup' ? 'XXXX-XXXX' : '000000' ?>" required autofocus></label>
                <button class="pp2fa-act" type="submit"><i class="bi bi-check2"></i> Verify &amp; sign in</button>
            </form>
            <?php endif; ?>
            <div class="pp2fa-alt">
                <?php if ($view !== 'code' && $method === 'totp'): ?><a href="<?= APP_BASE ?>/generated/auth/two-factor-verify.php"><i class="bi bi-phone"></i> Use my authenticator</a><?php endif; ?>
                <?php if ($method !== 'totp' && $view !== 'send'): ?>
                <form method="post" style="margin:0"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="do" value="send"><button type="submit"><i class="bi bi-arrow-repeat"></i> Send a new code</button></form>
                <?php endif; ?>
                <?php if ($view !== 'backup'): ?>
                <form method="post" style="margin:0"><input type="hidden" name="_token" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="do" value="show-backup"><button type="submit" formnovalidate><i class="bi bi-key"></i> Use a backup code</button></form>
                <?php endif; ?>
                <a class="pp2fa-quit" href="<?= APP_BASE ?>/generated/auth/logout.php"><i class="bi bi-x-lg"></i> Cancel and sign out</a>
            </div>
        </div>
    </main>
</div>
</body></html>
VERIFY;

        $keys = array_keys($tok); $vals = array_values($tok);
        $setup = str_replace(array_merge(['__HEAD__', '__CSS__'], $keys), array_merge([$head, $css], $vals), $setup);
        $verify = str_replace(array_merge(['__HEAD__', '__AUTHCSS__'], $keys), array_merge([$head, $authCss], $vals), $verify);
        $written[] = $this->writeFile($authDir . '/two-factor.php', $setup);
        $written[] = $this->writeFile($authDir . '/two-factor-verify.php', $verify);
        return $written;
    }

    private function authHeadTags(): string
    {
        return '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css">'
            . '<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css">';
    }

    private function authPageContent(string $title, string $phpLogic): string
    {
        $titleEsc = addslashes($title);
        return <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\$error = \$error ?? '';
\$message = \$message ?? '';
$phpLogic
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$titleEsc</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"></head>
<body class="ppd-auth-page">
<div class="ppd-auth-form">
<h2>$titleEsc</h2>
<?= flashMessage() ?>
<?php if (\$error): ?><div class="alert alert-danger"><?= htmlspecialchars(\$error) ?></div><?php endif; ?>
<?php if (\$message): ?><div class="alert alert-info"><?= htmlspecialchars(\$message) ?></div><?php endif; ?>
<form method="post">
<?= csrfField() ?>
<label><span>Username</span><input name="username" required></label>
<label><span>Password</span><input name="password" type="password" required></label>
<label style="flex-direction:row;gap:0.5rem"><input name="remember" type="checkbox" value="1"><span style="min-width:auto">Remember me</span></label>
<button class="ppd-auth-action" type="submit">$titleEsc</button>
</form>
<p style="margin-top:1rem;text-align:center">
<?php if (str_ends_with(\$_SERVER['SCRIPT_NAME'] ?? '', 'login.php')): ?>
<a href="<?= APP_BASE ?>/generated/auth/register.php">Create Account</a> | <a href="<?= APP_BASE ?>/generated/auth/forgot-password.php">Forgot Password?</a>
<?php endif; ?>
</p>
</div></body></html>
PHP;
    }

    /**
     * Render a design's presentational components (KPI stats, charts, cards,
     * headings…) via ComponentRenderer, mirroring the canvas. Used by the
     * dashboard (and reusable anywhere a page shows designed extras).
     */
    /**
     * Does rendered component HTML carry anything worth showing?
     *
     * Headings and empty widget shells alone are treated as "no" — they are
     * what an untouched dashboard scaffold produces, and stacking them under a
     * designed page makes the page look broken.
     */
    private static function hasRenderedContent(string $html): bool
    {
        if (trim($html) === '') return false;
        // Drop heading elements and their text, then look for any remaining
        // text, form control, link, image or data-bound PHP block.
        $stripped = preg_replace('#<h[1-6][^>]*>.*?</h[1-6]>#is', '', $html) ?? $html;
        // A widget header is chrome, not content — an untouched scaffold renders
        // "Quick links" over an empty list, which must not count as substance.
        $stripped = preg_replace('#<div class="ppd-widget-h">.*?</div>#is', '', $stripped) ?? $stripped;
        // A Section header (title/icon) is chrome too — an auto-seeded "Executive
        // Dashboard" scaffold is just titled Sections wrapping empty rows, which
        // must NOT roll up onto the landing page. (A Section with real widgets
        // inside still counts, because its BODY survives this strip.)
        $stripped = preg_replace('#<div class="h hh-[^"]*"[^>]*>.*?</div>#is', '', $stripped) ?? $stripped;
        if (preg_match('#<(?:a|input|select|textarea|img|svg|table|canvas)\b#i', $stripped)) return true;
        if (str_contains($stripped, '<?')) return true;   // data-bound (kpi/chart)
        return trim(strip_tags($stripped)) !== '';
    }

    private function componentExtrasHtml(array $components, array $skipTypes = []): string
    {
        // Layout containers (section, grid, cells, rows…) that hold ONLY skipped
        // components render as empty chrome. On the View page the fields live in
        // skipTypes (they're shown in the Details card), so the default View
        // template's field section would emit an empty "Basic Information" card.
        // Prune any such container whose whole subtree has no renderable content.
        $containerTypes = ['section', 'layout-grid', 'cell-grid', 'grid', 'row', 'cell', 'column', 'group', 'fieldset', 'form', 'tab', 'tabs'];
        $hasContent = null;
        $hasContent = function (array $c) use (&$hasContent, $skipTypes, $containerTypes): bool {
            $type = (string) ($c['type'] ?? '');
            if (in_array($type, $skipTypes, true)) return false;
            if (($c['visibility']['visible'] ?? true) === false) return false;
            if (!in_array($type, $containerTypes, true)) {
                return ComponentRenderer::handles($type); // a renderable non-container leaf = real content
            }
            foreach ((array) ($c['children'] ?? []) as $ch) {
                if (is_array($ch) && $hasContent($ch)) return true;
            }
            return false;
        };

        $walk = null;
        $walk = function (array $comps) use (&$walk, $skipTypes, $containerTypes, $hasContent): string {
            $out = '';
            foreach ($comps as $c) {
                if (!is_array($c)) continue;
                $type = (string) ($c['type'] ?? '');
                if (in_array($type, $skipTypes, true)) continue;
                if (($c['visibility']['visible'] ?? true) === false) continue;
                // The default templates' page-title heading (fixed id 'page_heading')
                // is redundant here — every generated page renders its own title/
                // record header. Add/Edit forms render their heading via a separate
                // path, so this only strips the stray "View X" heading on view/menu.
                if (($c['id'] ?? '') === 'page_heading') continue;
                // Drop empty layout containers (e.g. the View template's field
                // section once its fields are skipped) so no hollow card is emitted.
                if (in_array($type, $containerTypes, true) && !$hasContent($c)) continue;
                if (ComponentRenderer::handles($type)) {
                    $out .= $this->wrapComponent($c, ComponentRenderer::render(
                        $c,
                        static fn (array $node): string => $walk((array) ($node['children'] ?? []))
                    )) . "\n";
                } elseif (!empty($c['children'])) {
                    $out .= $walk((array) $c['children']);
                }
            }
            return $out;
        };
        return $walk($components);
    }

    private function generateDashboardPage(string $outputDir, array $tables): array
    {
        $this->log('Generating dashboard page…');
        $dir = $outputDir . '/generated/dashboard';
        if (!is_dir($dir)) mkdir($dir, 0755, true);

        // ── Menu (landing) page ──────────────────────────────────────────────
        // The Designer's "Menu" common page IS this page. If the developer has
        // designed it, render that; otherwise fall back to the shared default
        // tree so a project that never opened the Designer still lands on a
        // real page. Both paths go through the same renderer, so the Designer
        // canvas and the built page cannot drift.
        $menuDefaults = [
            'appName'    => (string) ($this->ctx['miscProject']['appName'] ?? ($this->ctx['project']['name'] ?? '')),
            'auth'       => !empty($this->ctx['security']['db_auth_enabled']),
            'brandImage' => trim((string) ((($this->ctx['shell'] ?? [])['logo']['image']) ?? '')),
        ];
        $menuDesign = $this->commonDesignFor('menu');
        $menuHtml = $this->componentExtrasHtml((array) ($menuDesign['components'] ?? []));
        // Did the owner actually DESIGN the Menu (landing) page? (renders to real
        // content, vs an empty/auto-seeded skeleton). Decides the fallback below.
        $menuDesigned = trim($menuHtml) !== '';

        // Designed dashboard widgets: every table's "dashboard" page design
        // contributes its components (KPI stats, charts…) to the app dashboard.
        $extraHtml = '';
        foreach (array_keys($tables) as $tn) {
            $dd = $this->designFor((string) $tn, 'dashboard');
            if (empty($dd['components'])) continue;
            $chunk = $this->componentExtrasHtml((array) $dd['components'],
                ['list-toolbar', 'selection-bar', 'pagination', 'table-grid', 'breadcrumb', 'buttons', 'filter-chips', 'field', 'form']);
            // Skip designs that amount to nothing. An empty dashboard scaffold
            // renders a bare heading plus an empty widget shell, which stacked
            // duplicate "Executive Dashboard / Quick links" blocks under the
            // landing page. "Has any real content" = something beyond headings
            // and empty containers.
            if (!self::hasRenderedContent($chunk)) continue;
            $extraHtml .= $chunk;
        }
        $hasDesignedDashboard = trim($extraHtml) !== '';

        // Compose the landing page:
        //  • Menu page designed         → use it (a designed dashboard still shows below).
        //  • Not designed, dashboard IS → the DESIGNED DASHBOARD is the whole page; do NOT
        //    prepend the default greeting/clock/stat-tiles/module-cards block. This is what
        //    a curated template (e.g. Legal Case Management) wants — its own dashboard, clean.
        //  • Neither designed           → the shared default landing (greeting + module cards),
        //    so a fresh project that never opened the Designer still lands on a real page.
        if (!$menuDesigned) {
            if ($hasDesignedDashboard) {
                $menuHtml = '';
                $this->log('Dashboard page: rendering the designed dashboard only (default landing suppressed)');
            } else {
                $menuHtml = $this->componentExtrasHtml(
                    \PpPhp\Core\Designer\Defaults\MenuPageDefault::components(array_keys($tables), $menuDefaults)
                );
                $this->log('Menu page: no renderable design — using the default landing page');
            }
        }
        // Wrap the menu block only when there is one, so an empty landing leaves no stray box.
        $menuBlock = $menuHtml !== '' ? '<div class="pp-menu-page">' . "\n" . $menuHtml . "\n" . '</div>' : '';
        // Wrap the designed dashboard in the SAME 12-column grid the Designer canvas
        // uses, so every top-level component's Size & Layout span (--sp) actually
        // applies — components nested in a Section already inherit that section's grid.
        $dashBlock = $extraHtml !== '' ? '<div class="pp-zone12 gap-normal ppd-page-body">' . "\n" . $extraHtml . "\n" . '</div>' : '';

        $statCards = '';
        foreach ($tables as $tableName => $fields) {
            $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));
            $statCards .= <<<CARD
<div class="ppd-dashboard-widget ppd-span-3">
<div class="ppd-stat-icon"><i class="bi bi-table"></i></div>
<div class="ppd-stat-body">
<span>$tableLabel</span>
<strong><?= \$stats['$tableName'] ?? 0 ?></strong>
</div>
<a href="<?= APP_BASE ?>/generated/$tableName/list.php" style="display:block;margin-top:0.5rem;font-size:0.875rem">View Records &rarr;</a>
</div>
CARD;
        }

        $tableList = '';
        foreach ($tables as $tableName => $fields) {
            $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));
            $tableList .= "'$tableName', ";
        }
        $tableList = rtrim($tableList, ', ');

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
\$stats = [];
foreach ([$tableList] as \$tbl) {
    // RLS/sqlWhere-scoped so the tile counts match the list page and never leak
    // the global row count of a table the user is restricted from (see api/count.php).
    try {
        \$where = class_exists('\PpPhp\Core\Auth\RowSecurity') ? \PpPhp\Core\Auth\RowSecurity::mergeWhere('', \$tbl) : '';
        \$__extra = \PpPhp\Core\Events::fire("tables.\$tbl.sql.sqlWhere", ['user' => \$_SESSION['user'] ?? [], 'params' => \$_GET]);
        if (is_string(\$__extra) && trim(\$__extra) !== '') {
            \$where = \$where === '' ? 'WHERE (' . \$__extra . ')' : \$where . ' AND (' . \$__extra . ')';
        }
        \$stats[\$tbl] = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `\$tbl` \$where");
    } catch (\Throwable) {
        \$stats[\$tbl] = 0;
    }
}
\$user = \PpPhp\Core\Security::CurrentUserData();
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dashboard</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css"></head>
<body data-pp-action="dashboard">
<?php pp_shell_start('', 'dashboard'); ?>
$menuBlock
$dashBlock
<?php pp_shell_end(); ?>
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/js/events.js"></script>
</body></html>
PHP;

        $path = $dir . '/index.php';
        file_put_contents($path, $content);
        $this->log('Wrote generated/dashboard/index.php');
        return [$path];
    }

    /**
     * Generate the user's custom (blank) pages for a table. Each `custom_<slug>`
     * page in the designer state becomes `generated/<table>/<slug>.php`, rendering
     * whatever components were placed on the responsive grid — a report, chart,
     * mobile-tailored list or any bespoke screen — inside the standard app shell.
     * Reuses the same component renderer + page chrome as every other page, so a
     * custom page cannot drift from the Designer canvas.
     *
     * @return array<int,string> written file paths
     */
    private function generateCustomPages(string $outputDir, string $tableName): array
    {
        $written = [];
        $keep = [];
        // Standalone pages (sentinel key) are table-less: guard by auth only (no
        // per-table permission) and give the shell no table context.
        $standalone = ($tableName === '__standalone__');
        $guardLine = $standalone
            ? '\PpPhp\Core\Security::RequireAuth();'
            : "\\PpPhp\\Core\\Auth\\PageAccess::guard('view', '$tableName', false);";
        $shellTable = $standalone ? '' : $tableName;
        $byType = (array) (($this->ctx['designer']['pages'][$tableName] ?? []));
        foreach ($byType as $pt => $slot) {
            if (!is_string($pt) || !str_starts_with($pt, 'custom_')) {
                continue;
            }
            $design = $this->designFor($tableName, $pt);
            $title  = trim((string) ($design['title'] ?? '')) ?: 'Page';
            // Render every placed component (no skips — the user chose them). Add
            // the page-chrome (theme/width/align + custom CSS/JS) like other pages.
            $extraHtml = empty($design['components'])
                ? '<div class="ppd-page-empty" style="padding:48px;text-align:center;color:var(--pp-muted,#64748b)">This page has no components yet — open it in the Designer and drag components from the palette.</div>'
                // Wrap in the 12-column grid so each component's Size & Layout span
                // (--sp on .pp-grid-item) actually applies — same grid the canvas uses.
                : '<div class="pp-zone12 gap-normal">' . $this->componentExtrasHtml((array) $design['components']) . '</div>';
            $extraHtml .= $this->pageChromeHtml($design);

            $titleEsc = htmlspecialchars($title, ENT_QUOTES);
            // SEO & social-sharing meta (Properties → SEO & sharing): description,
            // Open Graph + Twitter card, and an optional noindex.
            $seo = (array) ($design['settings'] ?? []);
            $metaDesc = trim((string) ($seo['description'] ?? ''));
            $ogImage  = trim((string) ($seo['ogImage'] ?? ''));
            $metaTags = '<meta property="og:type" content="website"><meta property="og:title" content="' . $titleEsc . '"><meta name="twitter:title" content="' . $titleEsc . '">';
            if ($metaDesc !== '') {
                $d = htmlspecialchars($metaDesc, ENT_QUOTES);
                $metaTags .= '<meta name="description" content="' . $d . '"><meta property="og:description" content="' . $d . '"><meta name="twitter:description" content="' . $d . '">';
            }
            if ($ogImage !== '' && preg_match('~^https?://~i', $ogImage)) {
                $img = htmlspecialchars($ogImage, ENT_QUOTES);
                $metaTags .= '<meta property="og:image" content="' . $img . '"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="' . $img . '">';
            } else {
                $metaTags .= '<meta name="twitter:card" content="summary">';
            }
            if (filter_var($seo['noindex'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                $metaTags .= '<meta name="robots" content="noindex, nofollow">';
            }
            // Favicon (SEO & sharing) — the browser-tab icon; falls back to the
            // Style module's logo image so tabs are branded with no extra work.
            $favicon = trim((string) ($seo['favicon'] ?? ''));
            if ($favicon === '') {
                $__sh = (array) ($this->ctx['shell'] ?? []);
                $favicon = trim((string) ($__sh['favicon'] ?? '')) ?: trim((string) (($__sh['logo']['image']) ?? ''));
            }
            if ($favicon !== '' && !preg_match('~^\s*(javascript|vbscript)\s*:~i', $favicon)) {
                $fav = htmlspecialchars($favicon, ENT_QUOTES);
                // A relative path (e.g. an uploaded Custom File like /custom/favicon.png)
                // is resolved against the app root at runtime; URLs/data URIs pass through.
                if (!preg_match('~^(https?:)?//|^data:~i', $favicon)) {
                    $fav = '<?= APP_BASE ?>/' . ltrim($fav, '/');
                }
                $metaTags .= '<link rel="icon" href="' . $fav . '"><link rel="apple-touch-icon" href="' . $fav . '">';
            }
            // Canonical URL (SEO & sharing) — the preferred address for this page.
            $canonical = trim((string) ($seo['canonical'] ?? ''));
            if ($canonical !== '' && preg_match('~^https?://~i', $canonical)) {
                $metaTags .= '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES) . '">';
            }
            // JSON-LD FAQ schema — auto-generated from any FAQ component(s) on the
            // page, so Google can surface FAQ rich results with no extra work.
            $faqItems = [];
            $collectFaq = function ($comps) use (&$collectFaq, &$faqItems) {
                foreach ((array) $comps as $c) {
                    if (!is_array($c)) continue;
                    if (($c['type'] ?? '') === 'faq') {
                        foreach ((array) (($c['props']['items'] ?? [])) as $it) {
                            $it = (array) $it;
                            $q = trim((string) ($it['q'] ?? '')); $an = trim((string) ($it['a'] ?? ''));
                            if ($q !== '' && $an !== '') $faqItems[] = ['@type' => 'Question', 'name' => $q, 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $an]];
                        }
                    }
                    if (!empty($c['children'])) $collectFaq($c['children']);
                }
            };
            $collectFaq($design['components'] ?? []);
            if ($faqItems !== []) {
                $ld = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $faqItems];
                $metaTags .= '<script type="application/ld+json">' . json_encode($ld, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) . '</script>';
            }
            // Organization + WebSite JSON-LD — auto from the Style brand (name + logo)
            // so search engines can attribute the site to a brand. Absolute-URL fields
            // only (schema.org expects full URLs); emitted only when a brand name exists.
            $shLogo = (array) ((($this->ctx['shell'] ?? [])['logo']) ?? []);
            $brandName = trim((string) ($shLogo['text'] ?? '')) ?: trim((string) ($this->ctx['title'] ?? '')) ?: trim((string) $title);
            if ($brandName !== '') {
                $ldj = static fn (array $x): string => '<script type="application/ld+json">' . json_encode($x, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) . '</script>';
                $site = ['@context' => 'https://schema.org', '@type' => 'WebSite', 'name' => $brandName];
                if ($metaDesc !== '') $site['description'] = $metaDesc;
                if ($canonical !== '' && preg_match('~^https?://~i', $canonical)) $site['url'] = $canonical;
                $org = ['@context' => 'https://schema.org', '@type' => 'Organization', 'name' => $brandName];
                $brandImg = trim((string) ($shLogo['image'] ?? ''));
                if ($brandImg !== '' && preg_match('~^https?://~i', $brandImg)) $org['logo'] = $brandImg;
                if ($canonical !== '' && preg_match('~^https?://~i', $canonical)) $org['url'] = $canonical;
                $metaTags .= $ldj($site) . $ldj($org);

                // Article JSON-LD (SEO → Content type = Article): blog-post rich data.
                if ((string) ($seo['contentType'] ?? 'page') === 'article') {
                    $art = ['@context' => 'https://schema.org', '@type' => 'Article', 'headline' => $title];
                    if ($metaDesc !== '') $art['description'] = $metaDesc;
                    if ($ogImage !== '' && preg_match('~^https?://~i', $ogImage)) $art['image'] = $ogImage;
                    $author = trim((string) ($seo['author'] ?? ''));
                    if ($author !== '') $art['author'] = ['@type' => 'Person', 'name' => $author];
                    $pub = trim((string) ($seo['published'] ?? ''));
                    if ($pub !== '') $art['datePublished'] = $pub;
                    if ($canonical !== '' && preg_match('~^https?://~i', $canonical)) $art['mainEntityOfPage'] = $canonical;
                    $pubOrg = ['@type' => 'Organization', 'name' => $brandName];
                    if (!empty($org['logo'])) $pubOrg['logo'] = ['@type' => 'ImageObject', 'url' => $org['logo']];
                    $art['publisher'] = $pubOrg;
                    $metaTags .= $ldj($art);
                }
            }
            // BreadcrumbList JSON-LD — Home → this page. Only when a canonical gives
            // us proper absolute URLs, and only for pages that aren't the site root.
            if ($canonical !== '' && preg_match('~^(https?://[^/]+)~i', $canonical, $rm)) {
                $siteRoot = $rm[1] . '/';
                if (rtrim($canonical, '/') !== rtrim($siteRoot, '/')) {
                    $bc = ['@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => [
                        ['@type' => 'ListItem', 'position' => 1, 'name' => 'Home', 'item' => $siteRoot],
                        ['@type' => 'ListItem', 'position' => 2, 'name' => $title, 'item' => $canonical],
                    ]];
                    $metaTags .= '<script type="application/ld+json">' . json_encode($bc, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) . '</script>';
                }
            }
            // Raw head code (Advanced → Head code): analytics / verification / fonts.
            // Owner-supplied, like Custom CSS/JS; inserted verbatim into <head>.
            $metaTags .= (string) ($seo['headCode'] ?? '');
            $dir = $outputDir . '/generated/' . $tableName;
            if (!is_dir($dir)) {
                mkdir($dir, 0755, true);
            }
            // Page frame: 'shell' wraps the page in the app chrome (navbar + sidebar
            // + footer via pp_shell_start/end); 'blank' is a chromeless landing page
            // that owns the full viewport (its own Navbar/Footer components). Theme,
            // fonts and component CSS load either way.
            $chrome = (string) (((array) ($design['layout'] ?? []))['chrome'] ?? 'shell');
            if ($chrome !== 'blank') $chrome = 'shell';
            // Per-page dark mode: add `is-dark` to <html> so the theme's dark-variant
            // tokens (:root.is-dark from theme.css) apply to the whole page.
            $htmlCls = filter_var(((array) ($design['layout'] ?? []))['dark'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' class="is-dark"' : '';
            if ($chrome === 'blank') {
                $bodyAttr = 'class="pp-landing-body" data-pp-table="' . $shellTable . '" data-pp-action="custom"';
                $shellOpen  = '<main class="pp-landing ppd-page-body">';
                $shellClose = '</main>';
            } else {
                $bodyAttr = 'data-pp-table="' . $shellTable . '" data-pp-action="custom"';
                $shellOpen = "<?php pp_shell_start('$titleEsc', '$shellTable',\n"
                    . "    '<nav class=\"pp-breadcrumb\"><a href=\"' . APP_BASE . '/\">Home</a><i class=\"bi bi-chevron-right sep\"></i><span>$titleEsc</span></nav>'); ?>\n"
                    . '<div class="pp-content-inner ppd-page-body">';
                $shellClose = "</div>\n<?php pp_shell_end(); ?>";
            }
            $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
$guardLine
?><!DOCTYPE html>
<html lang="en"$htmlCls>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$titleEsc</title>$metaTags<link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css"></head>
<body $bodyAttr>
$shellOpen
$extraHtml
$shellClose
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/js/events.js"></script>
</body>
</html>
PHP;
            $written[] = $this->writeFile($dir . '/' . $pt . '.php', $content);
            $keep[$pt] = true;
            $this->log("Wrote generated/$tableName/$pt.php (custom page: $title)");
        }
        // Remove files for custom pages that were deleted in the Designer, so a
        // rebuild reflects deletions and never serves a stale page.
        $tableDir = $outputDir . '/generated/' . $tableName;
        if (is_dir($tableDir)) {
            foreach (glob($tableDir . '/custom_*.php') ?: [] as $stale) {
                if (empty($keep[basename($stale, '.php')])) {
                    @unlink($stale);
                    $this->log('Removed deleted custom page ' . basename($stale));
                }
            }
        }
        return $written;
    }

    /**
     * SEO files for a standalone (database-free) site: a dynamic sitemap.php that
     * lists the indexable landing pages with runtime-absolute URLs, plus robots.txt.
     * The DB-driven sitemap enumerates records; standalone pages need this instead.
     */
    private function generateStandaloneSeoFiles(string $outputDir, string $slug): array
    {
        if (!\PpPhp\Core\ProjectDatabaseProfile::isStandaloneSlug($slug)) {
            return [];
        }
        $written = [];
        // Branded 404 page (served by index.php on any unmatched route). Uses the
        // theme + brand so a not-found page still looks like the site.
        $shLogo = (array) ((($this->ctx['shell'] ?? [])['logo']) ?? []);
        $brandName = trim((string) ($shLogo['text'] ?? '')) ?: trim((string) ($this->ctx['title'] ?? '')) ?: ucwords(str_replace(['-', '_'], ' ', $slug));
        $brandNameEsc = htmlspecialchars($brandName, ENT_QUOTES);
        $brandImg = trim((string) ($shLogo['image'] ?? ''));
        $brandBlock = $brandImg !== ''
            ? '<img src="' . htmlspecialchars($brandImg, ENT_QUOTES) . '" alt="' . $brandNameEsc . '" style="height:34px;margin-bottom:26px">'
            : '<div style="font-weight:800;font-size:19px;margin-bottom:26px;color:var(--pp-text,#0f172a)">' . $brandNameEsc . '</div>';
        $c404 = <<<PHP
<?php
require_once __DIR__ . '/_runtime.php';
http_response_code(404);
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>404 — $brandNameEsc</title><meta name="robots" content="noindex"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"></head>
<body class="pp-landing-body" style="margin:0">
<main class="pp-landing" style="min-height:100vh;display:grid;place-items:center;text-align:center;padding:40px;font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--pp-bg,var(--pp-surface,#fff));color:var(--pp-text,#0f172a)">
  <div style="max-width:460px">
    $brandBlock
    <div style="font-size:clamp(72px,16vw,140px);font-weight:850;line-height:1;letter-spacing:-.03em;color:var(--pp-primary,#4f46e5)">404</div>
    <h1 style="margin:12px 0 8px;font-size:24px;font-weight:800">Page not found</h1>
    <p style="margin:0 0 26px;font-size:16px;line-height:1.55;color:var(--pp-muted,#64748b)">The page you’re looking for doesn’t exist or may have moved.</p>
    <a href="<?= APP_BASE ?>/" style="display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:12px;font-weight:700;text-decoration:none;background:var(--pp-primary,#4f46e5);color:var(--pp-primary-text,#fff)">← Back to home</a>
  </div>
</main>
</body>
</html>
PHP;
        $written[] = $this->writeFile($outputDir . '/404.php', $c404);
        $byType = (array) (($this->ctx['designer']['pages']['__standalone__'] ?? []));
        $pages = [];
        foreach ($byType as $pt => $slot) {
            if (!is_string($pt) || !str_starts_with($pt, 'custom_')) {
                continue;
            }
            $design = $this->designFor('__standalone__', $pt);
            if (filter_var(((array) ($design['settings'] ?? []))['noindex'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                continue; // page opted out of search engines
            }
            $pages[$pt] = true;
        }
        if ($pages === []) {
            return $written;   // 404 written; no indexable pages → no sitemap
        }
        // The landing/home page is served at '/'; every other page at its file route.
        $home = isset($pages['custom_home']) ? 'custom_home' : (isset($pages['custom_index']) ? 'custom_index' : (string) array_key_first($pages));
        $routes = [];
        foreach (array_keys($pages) as $pt) {
            $routes[] = $pt === $home ? '/' : '/generated/__standalone__/' . $pt . '.php';
        }
        $routesExport = var_export(array_values(array_unique($routes)), true);
        $sitemap = <<<PHP
<?php
// Dynamic XML sitemap for this standalone site — absolute URLs built from the
// request host, so it works on any domain with no build-time configuration.
require_once __DIR__ . '/_runtime.php';
\$scheme = (!empty(\$_SERVER['HTTPS']) && \$_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
\$base = isset(\$_SERVER['HTTP_HOST']) ? \$scheme . '://' . \$_SERVER['HTTP_HOST'] . rtrim(APP_BASE, '/') : rtrim(APP_BASE, '/');
\$routes = $routesExport;
header('Content-Type: application/xml; charset=UTF-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\\n";
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\\n";
foreach (\$routes as \$r) {
    echo '  <url><loc>' . htmlspecialchars(\$base . \$r, ENT_QUOTES) . '</loc><changefreq>weekly</changefreq></url>' . "\\n";
}
echo '</urlset>' . "\\n";
PHP;
        $written[] = $this->writeFile($outputDir . '/sitemap.php', $sitemap);
        // robots.txt — allow all; reference the sitemap absolutely when a Base URL
        // is configured (Output → Base URL), else leave a hint (it lives at /sitemap.php).
        $robots = "User-agent: *\nAllow: /\n";
        $baseUrl = trim((string) $this->outBaseUrl);
        if ($baseUrl !== '' && preg_match('~^https?://~i', $baseUrl)) {
            $robots .= 'Sitemap: ' . rtrim($baseUrl, '/') . "/sitemap.php\n";
        } else {
            $robots .= "# Sitemap: <your-site-url>/sitemap.php  (set Output → Base URL for auto-discovery)\n";
        }
        $written[] = $this->writeFile($outputDir . '/robots.txt', $robots);
        $this->log('Wrote standalone sitemap.php + robots.txt (' . count($routes) . ' page(s))');
        return $written;
    }

    private function generateTableListPage(string $outputDir, string $tableName, array $fields): array
    {
        $this->log("Generating list page for table: $tableName");
        $dir = $outputDir . '/generated/' . $tableName;
        if (!is_dir($dir)) mkdir($dir, 0755, true);

        $searchableFields = [];
        $sortFields = [];
        foreach ($fields as $f) {
            if (in_array($f['type'], ['text', 'textarea', 'select', 'email'], true)) {
                $searchableFields[] = $f['name'];
            }
            $sortFields[] = $f['name'];
        }
        $searchableStr = var_export($searchableFields, true);
        $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));
        $fieldNamesStr = '[' . implode(', ', array_map(function($f) {
            return var_export($f['name'], true);
        }, $fields)) . ']';

        // ── Design + module settings for this list page ──
        $design = $this->designFor($tableName, 'list');
        // Prefer the Designer's page title as the shell-title fallback (the Misc →
        // Page Titles dictionary still overrides per-language via pp_page_title);
        // otherwise fall back to the runtime table caption.
        $listDesignTitle = str_replace(["\r", "\n"], ' ', trim((string) ($design['title'] ?? '')));
        $listTitleFallback = $listDesignTitle !== '' ? var_export($listDesignTitle, true) : '$__caption';
        $props  = $this->componentProps($design);
        $nodes  = $this->componentNodes($design);
        $fn     = $this->functionalityFor($tableName);
        $misc   = $this->miscFor($tableName);
        $types  = $this->pageTypesFor($tableName);

        // Component chrome from the Style panel (class / inline style / custom CSS / visible)
        $chrome = [];
        foreach (['list-toolbar', 'selection-bar', 'pagination', 'table-grid', 'breadcrumb', 'buttons', 'filter-chips'] as $ct) {
            $chrome[$ct] = $this->componentChrome($nodes[$ct] ?? null);
        }
        $attrOf = static function (array $c, string $baseClass = ''): string {
            $cls = trim($baseClass . ' ' . $c['class']);
            return ($cls !== '' ? ' class="' . htmlspecialchars($cls) . '"' : '')
                . ($c['style'] !== '' ? ' style="' . htmlspecialchars($c['style']) . '"' : '');
        };
        // Every top-level list component is a child of a 12-col grid (.pp-zone12),
        // so it honours the Designer's per-device Size & Layout span AND per-device
        // visibility — identical to the canvas. This returns the component's full
        // attribute string (pp-grid-item + base + Style-panel class, chrome style +
        // --sp/--spt/--spm vars + pp-hide-<dev>). WYSIWYG with the Designer.
        $itemAttr = function (string $type, string $baseClass) use ($nodes, $chrome): string {
            $node  = is_array($nodes[$type] ?? null) ? $nodes[$type] : [];
            $props = (array) ($node['props'] ?? []);
            $ch    = $chrome[$type] ?? ['class' => '', 'style' => ''];
            $spans = is_array($props['spans'] ?? null) ? $props['spans'] : [];
            $clamp = static fn($v, int $def): int => (is_numeric($v) && (int) $v >= 1 && (int) $v <= 12) ? (int) $v : $def;
            $dd = $clamp($spans['desktop'] ?? 12, 12);
            $tt = $clamp($spans['tablet'] ?? null, $dd);
            $mm = $clamp($spans['mobile'] ?? null, 12);
            $hideCls = '';
            foreach (array_filter((array) ($props['hideOn'] ?? []), static fn($x) => in_array($x, ['desktop', 'tablet', 'mobile'], true)) as $dev) {
                $hideCls .= ' pp-hide-' . $dev;
            }
            $cls   = trim('pp-grid-item ' . $baseClass . ' ' . ($ch['class'] ?? '') . $hideCls);
            $style = trim(($ch['style'] ?? '') . ';--sp:' . $dd . ';--spt:' . $tt . ';--spm:' . $mm, ';');
            return ' class="' . htmlspecialchars($cls) . '" style="' . htmlspecialchars($style) . '"';
        };
        // Just the span CSS vars for a component type (for wrappers built inline).
        $itemSpanVars = function (string $type) use ($nodes): string {
            $props = (array) (($nodes[$type] ?? [])['props'] ?? []);
            $spans = is_array($props['spans'] ?? null) ? $props['spans'] : [];
            $clamp = static fn($v, int $def): int => (is_numeric($v) && (int) $v >= 1 && (int) $v <= 12) ? (int) $v : $def;
            $dd = $clamp($spans['desktop'] ?? 12, 12);
            $tt = $clamp($spans['tablet'] ?? null, $dd);
            $mm = $clamp($spans['mobile'] ?? null, 12);
            return "--sp:$dd;--spt:$tt;--spm:$mm";
        };
        // Extra designed components (KPI stats, charts, headings, cards…) on
        // the list page render above the toolbar via ComponentRenderer,
        // mirroring the canvas. Known chrome + data internals are skipped.
        $knownListTypes = ['list-toolbar', 'selection-bar', 'pagination', 'table-grid', 'breadcrumb',
            'buttons', 'filter-chips', 'filter-panel', 'search', 'field', 'form', 'where-tabs'];
        $renderExtra = null;
        $renderExtra = function (array $comps) use (&$renderExtra, $knownListTypes): string {
            $out = '';
            foreach ($comps as $c) {
                if (!is_array($c)) continue;
                $type = (string) ($c['type'] ?? '');
                if (in_array($type, $knownListTypes, true)) continue;
                if (($c['visibility']['visible'] ?? true) === false) continue;
                if (ComponentRenderer::handles($type)) {
                    $out .= $this->wrapComponent($c, ComponentRenderer::render(
                        $c,
                        static fn (array $node): string => $renderExtra((array) ($node['children'] ?? []))
                    )) . "\n";
                } elseif (!empty($c['children'])) {
                    $out .= $renderExtra((array) $c['children']);
                }
            }
            return $out;
        };
        $extraHtml = $renderExtra((array) ($design['components'] ?? []));

        $collectedCss = array_filter(array_column($chrome, 'css'));
        // Page Properties panel → theme override + width/align/device + custom CSS/JS
        $__pgChrome = $this->pageChrome($design);
        $pageCss = trim($__pgChrome['css']);
        $pageJs  = $__pgChrome['js'];
        $pageScriptBlock = $pageJs !== '' ? "<script>\n$pageJs\n</script>" : '';

        // Misc → Table Settings is the single source of truth for list config
        // (the Designer's List-settings panel writes here too). Legacy designer
        // props are only a fallback when Table Settings has no value.
        $miscPerPage = (int) ($misc['recordsPerPage'] ?? 0);
        $perPage = $miscPerPage > 0
            ? $miscPerPage
            : (int) ($props['pagination']['pageSize'] ?? $props['list-toolbar']['pageSizeDefault'] ?? 25);
        if ($perPage < 1) $perPage = 25;
        $defaultDir = strtoupper((string) ($misc['defaultSortOrder'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
        // Search placeholder: a designer-set value stays literal; the default is
        // translatable at runtime (ui.search_records).
        $searchPhCustom = trim((string) ($props['list-toolbar']['searchPlaceholder'] ?? ''));
        $searchPlaceholder = addslashes($searchPhCustom !== '' ? $searchPhCustom : 'Search records...');
        // Empty OR a scaffolded default ("Search records..."/"…") → translatable.
        $searchPhIsDefault = ($searchPhCustom === '' || rtrim($searchPhCustom, '.…') === 'Search records');
        $searchPhAttr = !$searchPhIsDefault
            ? 'placeholder="' . addslashes(htmlspecialchars($searchPhCustom)) . '"'
            : 'placeholder="<?= htmlspecialchars(pp_t(\'ui.search_records\', \'Search records…\')) ?>"';
        $grid = (array) ($props['table-grid'] ?? []);
        $gridClass = trim(implode(' ', array_filter([
            !empty($grid['striped']) ? 'pp-striped' : '',
            (($grid['hover'] ?? true) || !empty($misc['rowHighlight'])) ? 'pp-hover' : '',
            !empty($grid['bordered']) ? 'pp-bordered' : '',
            !empty($grid['stickyHeader']) ? 'pp-sticky' : '',
            (isset($grid['wrapText']) && !$grid['wrapText']) ? 'pp-nowrap' : '',
            in_array($grid['density'] ?? '', ['compact', 'comfortable'], true) ? 'pp-' . $grid['density'] : '',
        ])));
        // Grid props previously shown on the canvas but dropped by the build:
        // caption (<caption>), rowNumbers (# column), headerFilters (funnel on
        // every column), conditionalRules (per-row/cell formatting). Now honored.
        $gridCaptionExport = var_export(trim((string) ($grid['caption'] ?? '')), true);
        $gridRowNumbers    = !empty($grid['rowNumbers']) ? 'true' : 'false';
        $gridHeaderFilters = !empty($grid['headerFilters']) ? 'true' : 'false';
        $gridCondRulesExport = var_export(array_values((array) ($grid['conditionalRules'] ?? [])), true);
        $gridResponsiveExport = var_export(($grid['responsive'] ?? 'scroll') === 'stack' ? 'stack' : 'scroll', true);
        // List-page runtime settings (Pages module → List Settings): scroll table,
        // reorder columns, resizable columns, click actions.
        $ls = $this->listSettingsFor($tableName);
        $lsScroll     = !empty($ls['scrollTable']);
        $lsReorderJs  = !empty($ls['reorderFields']) ? 'true' : 'false';
        $lsResizeJs   = !empty($ls['resizableColumns']) ? 'true' : 'false';
        $lsPinJs      = !empty($ls['pinColumns']) ? 'true' : 'false';
        $lsViewsJs    = !empty($ls['savedViews']) ? 'true' : 'false';
        $lsGroupJson  = json_encode((string) ($ls['groupBy'] ?? ''), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $lsKanbanJson = json_encode((string) ($ls['kanbanField'] ?? ''), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        // Client-safe click actions: drop the author's Server (PHP) source — it must
        // never reach the browser. The executeCode branch calls api/list-click.php by
        // $srvKey instead. (Previously the raw PHP was shipped in this JSON.)
        $lsClicksClient = array_map(static function (array $c): array {
            unset($c['server']);
            return $c;
        }, (array) $ls['clickActions']);
        $lsClicksJson = json_encode($lsClicksClient, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $hideUntilSearch = !empty($misc['hideUntilSearch']) ? 'true' : 'false';
        $perPageOptions = array_values(array_filter(array_map('intval', explode(',', (string) ($misc['recordsPerPageOptions'] ?? '')))));
        $perPageOptionsStr = var_export($perPageOptions, true);

        // Additional WHERE tabs (Query designer): developer-defined filter tabs on
        // the list page. $whereTabsRuntime applies the active tab's clause to the
        // list query; $whereTabsBar renders the tab strip. Both empty when none.
        $whereTabs = $this->whereTabsFor($tableName);
        $showAllTab = ($this->ctx['query'][$tableName]['whereTabsShowAll'] ?? true) ? 'true' : 'false';
        $whereTabsRuntime = '';
        $whereTabsBar = '';
        if ($whereTabs) {
            // Styling from the Designer "Filter Tabs" (where-tabs) node → scoped CSS.
            $wtp = (array) ($props['where-tabs'] ?? []);
            $wtVal = static function ($v): string { $v = trim((string) $v); return ($v === '' || preg_match('/[<>{};]/', $v)) ? '' : $v; };
            $wtSize = in_array(($wtp['size'] ?? 'normal'), ['compact', 'large'], true) ? (string) $wtp['size'] : 'normal';
            $wtPad  = $wtSize === 'compact' ? '.25rem .65rem' : ($wtSize === 'large' ? '.6rem 1.2rem' : '.4rem .9rem');
            $wtBaseFs = $wtSize === 'compact' ? '.82rem' : ($wtSize === 'large' ? '1rem' : '');
            $tabDecl = ['padding:' . $wtPad];
            if (($v = $wtVal($wtp['fontFamily'] ?? '')) !== '') $tabDecl[] = 'font-family:' . $v;
            $wtFs = $wtVal($wtp['fontSize'] ?? ''); $wtFs = $wtFs !== '' ? $wtFs : $wtBaseFs;
            if ($wtFs !== '') $tabDecl[] = 'font-size:' . $wtFs;
            if (($v = $wtVal($wtp['tabColor'] ?? '')) !== '') $tabDecl[] = 'color:' . $v;
            if (($v = $wtVal($wtp['tabBg'] ?? '')) !== '') $tabDecl[] = 'background:' . $v;
            $actDecl = [];
            if (($v = $wtVal($wtp['activeColor'] ?? '')) !== '') $actDecl[] = 'color:' . $v;
            if (($v = $wtVal($wtp['activeBg'] ?? '')) !== '') $actDecl[] = 'background:' . $v;
            if (($v = $wtVal($wtp['accent'] ?? '')) !== '') $actDecl[] = 'border-bottom-color:' . $v;
            $badgeDecl = [];
            if (($v = $wtVal($wtp['badgeColor'] ?? '')) !== '') $badgeDecl[] = 'color:' . $v;
            if (($v = $wtVal($wtp['badgeBg'] ?? '')) !== '') { $badgeDecl[] = 'background:' . $v; $badgeDecl[] = 'padding:0 .4rem'; $badgeDecl[] = 'border-radius:999px'; }
            if (($v = $wtVal($wtp['badgeSize'] ?? '')) !== '') $badgeDecl[] = 'font-size:' . $v;
            $wtCss = '.pp-where-tabs{margin:0 0 1rem;border-bottom:1px solid var(--pp-border,#e2e8f0);flex-wrap:wrap}'
                . '.pp-where-tabs .nav-link{cursor:pointer;color:var(--pp-primary,#2563eb);' . implode(';', $tabDecl) . '}'
                . '.pp-where-tabs .nav-link.active{' . ($actDecl ? implode(';', $actDecl) . ';' : 'color:var(--pp-text,#111827);') . 'font-weight:600}'
                . '.pp-wt-count{opacity:.7;font-weight:400;' . implode(';', $badgeDecl) . '}'
                . '.pp-where-tabs li[draggable]{cursor:grab}.pp-where-tabs li.pp-wt-drag{opacity:.45}'
                . '.pp-where-tabs li.pp-wt-over{box-shadow:inset 2px 0 0 var(--pp-accent,#4f46e5)}';

            // Per-tab overrides (Designer: select an individual tab) → inline styles
            // keyed by tab position. Applied to the Nth rendered tab + its badge.
            $wtTabCss = []; $wtBadgeCss = [];
            foreach ((array) ($wtp['tabStyles'] ?? []) as $__idx => $__ov) {
                if (!is_array($__ov)) continue; $__i2 = (int) $__idx; $td = []; $bd = [];
                if (($v = $wtVal($__ov['tabColor'] ?? '')) !== '') $td[] = 'color:' . $v;
                if (($v = $wtVal($__ov['tabBg'] ?? '')) !== '') $td[] = 'background:' . $v;
                if (($v = $wtVal($__ov['fontSize'] ?? '')) !== '') $td[] = 'font-size:' . $v;
                if (($v = $wtVal($__ov['fontFamily'] ?? '')) !== '') $td[] = 'font-family:' . $v;
                if (($v = $wtVal($__ov['padding'] ?? '')) !== '') $td[] = 'padding:' . $v;
                if ($td) $wtTabCss[$__i2] = implode(';', $td);
                if (($v = $wtVal($__ov['badgeColor'] ?? '')) !== '') $bd[] = 'color:' . $v;
                if (($v = $wtVal($__ov['badgeBg'] ?? '')) !== '') { $bd[] = 'background:' . $v; $bd[] = 'padding:0 .4rem'; $bd[] = 'border-radius:999px'; }
                if (($v = $wtVal($__ov['badgeSize'] ?? '')) !== '') $bd[] = 'font-size:' . $v;
                if ($bd) $wtBadgeCss[$__i2] = implode(';', $bd);
            }

            $whereTabsRuntime = str_replace(['__WT_EXPORT__', '__TBL__', '__WT_SHOWALL__'], [var_export($whereTabs, true), $tableName, $showAllTab], <<<'RUNTIME'
// Additional WHERE tabs (Query designer). "clause" tabs carry a fixed WHERE;
// "dynamic" tabs expand — at request time — into one tab per distinct value of
// a field, so the list auto-reflects the data (e.g. status → Active/Pending/…).
$__whereTabs = [];
foreach ((array) __WT_EXPORT__ as $__t) {
    if (($__t['type'] ?? 'clause') === 'dynamic') {
        $__f = (string) ($__t['field'] ?? '');
        if ($__f === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $__f)) continue;
        try {
            $__vwhere = \PpPhp\Core\Auth\RowSecurity::mergeWhere('', '__TBL__');
            $__stmt = \PpPhp\Core\DB::Pdo()->query("SELECT DISTINCT `$__f` AS v FROM `__TBL__` $__vwhere ORDER BY `$__f` LIMIT 40");
            $__vals = $__stmt ? $__stmt->fetchAll(\PDO::FETCH_COLUMN) : [];
        } catch (\Throwable $__e) { $__vals = []; }
        foreach ($__vals as $__v) {
            if ($__v === null || $__v === '') {
                $__whereTabs[] = ['name' => pp_t('ui.empty', '(empty)'), 'clause' => "(`$__f` IS NULL OR `$__f` = '')", 'showCount' => !empty($__t['showCount']), 'hideEmpty' => !empty($__t['hideEmpty'])];
            } else {
                $__q = \PpPhp\Core\DB::Pdo()->quote((string) $__v);
                $__whereTabs[] = ['name' => (string) $__v, 'clause' => "`$__f` = $__q", 'showCount' => !empty($__t['showCount']), 'hideEmpty' => !empty($__t['hideEmpty'])];
            }
        }
    } else {
        $__whereTabs[] = $__t;
    }
}
$__wtShowAll = __WT_SHOWALL__;
$__activeTab = isset($_GET['wtab']) && $_GET['wtab'] !== '' ? (int) $_GET['wtab'] : ($__wtShowAll ? -1 : 0);
if ($__activeTab >= 0 && isset($__whereTabs[$__activeTab])) {
    $__tabClause = trim((string) $__whereTabs[$__activeTab]['clause']);
    if ($__tabClause !== '' && class_exists('\PpPhp\Core\SqlVars')) $__tabClause = \PpPhp\Core\SqlVars::expand($__tabClause);
    if ($__tabClause !== '') $where = $where === '' ? 'WHERE (' . $__tabClause . ')' : $where . ' AND (' . $__tabClause . ')';
}
RUNTIME);
            $whereTabsBar = str_replace(['__TBL__', '__WT_STYLE__', '__WT_TABCSS__', '__WT_BADGECSS__', '__WT_SPAN__'], [$tableName, $wtCss, var_export($wtTabCss, true), var_export($wtBadgeCss, true), $itemSpanVars('where-tabs')], <<<'BAR'
<?php if (!empty($__whereTabs)): $__wtQ = $_GET; unset($__wtQ['wtab'], $__wtQ['page']); $__wtTabCss = __WT_TABCSS__; $__wtBadgeCss = __WT_BADGECSS__; ?>
<style>__WT_STYLE__</style>
<ul class="pp-grid-item pp-where-tabs nav nav-tabs" style="__WT_SPAN__" role="tablist" data-pp-wtsort="__TBL__">
<?php foreach ($__whereTabs as $__i => $__wt): $__wq = $__wtQ; $__wq['wtab'] = $__i; $__wcount = ''; $__wn = null;
    if (!empty($__wt['showCount']) || !empty($__wt['hideEmpty'])) { try { $__wc = trim((string) $__wt['clause']); if ($__wc !== '' && class_exists('\PpPhp\Core\SqlVars')) $__wc = \PpPhp\Core\SqlVars::expand($__wc); $__ww = $__wc !== '' ? 'WHERE (' . $__wc . ')' : ''; $__ww = \PpPhp\Core\Auth\RowSecurity::mergeWhere($__ww, '__TBL__'); $__wn = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `__TBL__` $__ww"); } catch (\Throwable $__e) { $__wn = null; } }
    if (!empty($__wt['hideEmpty']) && $__wn === 0 && $__activeTab !== $__i) continue;   // hide a tab with no data (keep it if it's the active view)
    $__bStyle = isset($__wtBadgeCss[$__i]) ? ' style="' . htmlspecialchars($__wtBadgeCss[$__i], ENT_QUOTES) . '"' : '';
    if (!empty($__wt['showCount']) && $__wn !== null) $__wcount = ' <span class="pp-wt-count"' . $__bStyle . '>(' . $__wn . ')</span>'; ?>
<li class="nav-item" draggable="true" data-wtkey="<?= htmlspecialchars($__wt['name'], ENT_QUOTES) ?>"><a class="nav-link<?= $__activeTab === $__i ? ' active' : '' ?>" draggable="false"<?= isset($__wtTabCss[$__i]) ? ' style="' . htmlspecialchars($__wtTabCss[$__i], ENT_QUOTES) . '"' : '' ?> href="?<?= htmlspecialchars(http_build_query($__wq)) ?>"><?= htmlspecialchars($__wt['name']) ?><?= $__wcount ?></a></li>
<?php endforeach; ?>
<?php if (!empty($__wtShowAll)): ?><li class="nav-item" draggable="true" data-wtkey="__all__"><a class="nav-link<?= $__activeTab < 0 ? ' active' : '' ?>" draggable="false" href="?<?= htmlspecialchars(http_build_query($__wtQ)) ?>"><?= pp_t('ui.all_data', 'All data') ?></a></li><?php endif; ?>
</ul>
<script>
/* Drag-to-reorder the filter tabs; the chosen order is remembered per table in
   this browser (localStorage). Each tab's href keeps its own wtab index, so the
   filter mapping is never affected by reordering. */
(function () {
    var ul = document.currentScript.previousElementSibling;
    while (ul && !(ul.classList && ul.classList.contains('pp-where-tabs'))) ul = ul.previousElementSibling;
    if (!ul) return;
    var key = 'pp_wtorder_' + ul.getAttribute('data-pp-wtsort');
    var lis = function () { return Array.prototype.slice.call(ul.querySelectorAll('li[data-wtkey]')); };
    try {
        var saved = JSON.parse(localStorage.getItem(key) || '[]');
        if (saved.length) { var map = {}; lis().forEach(function (li) { map[li.getAttribute('data-wtkey')] = li; }); saved.forEach(function (k) { if (map[k]) { ul.appendChild(map[k]); delete map[k]; } }); }
    } catch (e) {}
    function persist() { try { localStorage.setItem(key, JSON.stringify(lis().map(function (li) { return li.getAttribute('data-wtkey'); }))); } catch (e) {} }
    var drag = null;
    ul.addEventListener('dragstart', function (e) { var li = e.target.closest('li[data-wtkey]'); if (!li) return; drag = li; li.classList.add('pp-wt-drag'); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', ''); } catch (_) {} });
    ul.addEventListener('dragend', function () { lis().forEach(function (li) { li.classList.remove('pp-wt-over'); }); if (drag) { drag.classList.remove('pp-wt-drag'); drag = null; persist(); } });
    ul.addEventListener('dragover', function (e) { if (!drag) return; e.preventDefault(); var li = e.target.closest('li[data-wtkey]'); if (!li || li === drag) return; lis().forEach(function (x) { x.classList.remove('pp-wt-over'); }); li.classList.add('pp-wt-over'); var r = li.getBoundingClientRect(); var after = (e.clientX - r.left) / r.width > 0.5; ul.insertBefore(drag, after ? li.nextSibling : li); });
})();
</script>
<?php endif; ?>
BAR);
        }

        // Designed grid columns (order, visibility, labels, sortability, controls)
        $fieldsByName = [];
        foreach ($fields as $f) $fieldsByName[$f['name']] = $f;
        // A PRISTINE list page (auto-scaffolded, never hand-edited in the Designer)
        // must follow the Fields module's current per-page choices — NOT a stale
        // column snapshot an earlier scaffold may have capped (e.g. first 12 of 33).
        // Any real user edit clears `pristine`, at which point the designed columns
        // win verbatim. Empty snapshot → the same all-`list`-fields fallback below.
        $listPristine = !empty($design['pristine']);
        $gridColumns = $listPristine ? [] : array_values(array_filter(
            (array) ($grid['columns'] ?? []),
            static fn($c) => is_array($c) && !empty($c['field'])
        ));
        // Per-level cell styling: row defaults (props.rows) merged with per-column overrides
        $rowsDefaults = (array) ($grid['rows'] ?? []);
        $styleOf = static function (array $level): string {
            $ok = static fn(string $v): bool => $v !== '' && !preg_match('/[<>{};]/', $v);
            $css = [];
            if (!empty($level['align']) && in_array($level['align'], ['left', 'center', 'right'], true)) $css[] = 'text-align:' . $level['align'];
            $map = [
                'color' => 'color', 'background' => 'background', 'fontSize' => 'font-size',
                'font' => 'font-family', 'fontWeight' => 'font-weight', 'padding' => 'padding',
                'height' => 'height', 'border' => 'border', 'borderRadius' => 'border-radius',
            ];
            foreach ($map as $key => $prop) {
                $v = trim((string) ($level[$key] ?? ''));
                if ($ok($v)) $css[] = $prop . ':' . $v;
            }
            if (!empty($level['bold'])) $css[] = 'font-weight:700';
            $bStyle = trim((string) ($level['borderStyle'] ?? ''));
            $bWidth = trim((string) ($level['borderWidth'] ?? ''));
            $bColor = trim((string) ($level['borderColor'] ?? ''));
            if (($bStyle !== '' || $bWidth !== '' || $bColor !== '')
                && $ok($bStyle ?: 'solid') && $ok($bWidth ?: '1px') && ($bColor === '' || $ok($bColor))) {
                $css[] = 'border:' . ($bWidth ?: '1px') . ' ' . ($bStyle ?: 'solid') . ' ' . ($bColor ?: 'currentColor');
            }
            return implode(';', $css);
        };
        $hdrDefault  = (array) ($rowsDefaults['header'] ?? []);
        $cellDefault = (array) ($rowsDefaults['cell'] ?? []);
        $footDefault = (array) ($rowsDefaults['footer'] ?? []);

        // Columns whose Designer "Filter" property is on get a header funnel
        // (per-column opt-in). Populated in the designed-columns branch below.
        $headerFilterCols = [];
        if ($gridColumns !== []) {
            $listFields = [];
            foreach ($gridColumns as $col) {
                if (isset($col['visible']) && !$col['visible']) continue;
                $base = $fieldsByName[$col['field']] ?? ['name' => $col['field'], 'type' => 'text'];
                $colHeaderFilter = (bool) ($col['filter'] ?? false);
                if ($colHeaderFilter) $headerFilterCols[] = (string) $col['field'];
                $thStyle = $styleOf(array_merge($hdrDefault, (array) ($col['header'] ?? [])));
                $width = trim((string) ($col['width'] ?? ''));
                if ($width !== '' && $width !== 'auto' && !preg_match('/[<>{};]/', $width)) {
                    $thStyle = trim($thStyle . ';width:' . $width, ';');
                }
                $listFields[] = [
                    'name'     => (string) $col['field'],
                    'label'    => (string) ($col['label'] ?? $base['label'] ?? $col['field']),
                    'type'     => (string) ($base['type'] ?? 'text'),
                    'list'     => true,
                    'sortable' => $col['sortable'] ?? true,
                    'headerFilter' => $colHeaderFilter,
                    'viewCtl'  => (string) ($col['viewControl'] ?? ''),
                    'viewSettings'   => (array) ($col['viewSettings'] ?? []),
                    'editControl'    => (string) ($col['editControl'] ?? ''),
                    'editSettings'   => (array) ($col['editSettings'] ?? []),
                    'filterCtl'      => (string) ($col['filterControl'] ?? ''),
                    'filterSettings' => (array) ($col['filterSettings'] ?? []),
                    'thStyle'  => $thStyle,
                    // cell style + the field-token style (colour/font markup on the value itself)
                    'tdStyle'  => trim($styleOf(array_merge($cellDefault, (array) ($col['cell'] ?? [])))
                        . ';' . $styleOf((array) ($col['fieldStyle'] ?? [])), ';'),
                    'thClass'  => trim(preg_replace('/[^a-zA-Z0-9 _-]/', '', (string) (($col['header'] ?? [])['class'] ?? ($hdrDefault['class'] ?? '')))),
                    'tdClass'  => trim(preg_replace('/[^a-zA-Z0-9 _-]/', '',
                        (string) (($col['cell'] ?? [])['class'] ?? ($cellDefault['class'] ?? ''))
                        . ' ' . (string) (($col['fieldStyle'] ?? [])['class'] ?? ''))),
                ];
                // custom CSS attached to individual cells / rows / field tokens flows into the page style block
                foreach (['header', 'cell', 'footer', 'fieldStyle'] as $lvl) {
                    $partCss = trim((string) (($col[$lvl] ?? [])['customCss'] ?? ''));
                    if ($partCss !== '') $collectedCss[] = $partCss;
                }
            }
        } else {
            $listFields = array_map(static function (array $f): array {
                $f['sortable'] = true;
                $f['viewCtl'] = (string) ($f['viewAs'] ?? '');
                return $f;
            }, array_values(array_filter($fields, static fn(array $f): bool => (bool) ($f['list'] ?? true))));
        }

        // Grid-level "Filter row" master toggle: renderTable draws a header funnel
        // on EVERY column at runtime (its headerFilters param). Each funnel submits
        // f[col][] (array → IN), so no exact-filter registration is needed — only
        // the Set-Filter popup script must ship (see $setFilterScript gating below).
        // We deliberately do NOT add all columns to $headerFilterCols/exactFilters,
        // which would silently turn free-text Filter-panel inputs into exact match.
        $gridHeaderFiltersOn = !empty($grid['headerFilters']);

        // Aggregate footer (designed per-column footer.aggregate / legacy col.aggregate)
        // carries the designed label override + cell style (color/bg/font/size/align)
        $aggCols = [];
        foreach ($gridColumns as $col) {
            $foot = (array) ($col['footer'] ?? []);
            $agg = strtolower((string) (($foot['aggregate'] ?? '') ?: ($col['aggregate'] ?? '')));
            if (in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true) && (!isset($col['visible']) || $col['visible'])) {
                $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $col['field']);
                if ($safe !== '') {
                    $aggCols[$safe] = [
                        'fn'    => $agg,
                        'text'  => trim((string) ($foot['text'] ?? '')),
                        'style' => $styleOf(array_merge($footDefault, $foot)),
                    ];
                }
            }
        }
        // Designer per-column freeze: columns pinned left/right in the Designer
        // (props.columns[i].pin) become the generated list's default frozen columns
        // (WYSIWYG — what you pin in the Designer is frozen in the app). The runtime
        // pin block applies these as the initial state; end-user pinning (the
        // pinColumns toggle) can still override them per browser.
        $designPins = ['left' => [], 'right' => []];
        foreach ($gridColumns as $col) {
            $pin = strtolower((string) ($col['pin'] ?? ''));
            if (($pin === 'left' || $pin === 'right') && (!isset($col['visible']) || $col['visible'])) {
                $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $col['field']);
                if ($safe !== '') $designPins[$pin][] = $safe;
            }
        }
        $lsDesignPinsJson = json_encode($designPins, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?: '{"left":[],"right":[]}';
        // Group subtotals reuse the developer's designed footer aggregates: only
        // those columns get a per-group total, using the same function (never a
        // blind sum of every numeric column, which would total IDs / phone numbers).
        $lsGroupAggJson = json_encode(array_map(static fn(array $m): string => (string) $m['fn'], $aggCols), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?: '{}';
        $showTotals = (($grid['showTotals'] ?? true) && $aggCols !== []);
        // 'all records' (SQL over the full filtered set) vs 'displayed records'
        // (PHP over the fetched page) — designed on the grid's Behaviour panel
        $aggScope = str_starts_with((string) ($grid['aggregateScope'] ?? 'all'), 'displayed') ? 'page' : 'all';
        $aggPhp = "\n\$aggregates = [];";
        if ($showTotals && $aggScope === 'all') {
            $aggExprs = [];
            $aggAssign = '';
            foreach ($aggCols as $f => $meta) {
                $a = $meta['fn'];
                $aggExprs[] = ($a === 'count' ? "COUNT(`$f`)" : strtoupper($a) . "(`$f`)") . " AS `agg_$f`";
                $aggAssign .= "        \$aggregates['$f'] = ['fn' => '$a', 'value' => \$__aggRow['agg_$f'] ?? null, "
                    . "'text' => '" . addslashes($meta['text']) . "', 'style' => '" . addslashes($meta['style']) . "'];\n";
            }
            $aggSelect = implode(', ', $aggExprs);
            $aggPhp = "\n\$aggregates = [];\n"
                . "if (!\$hideUntilSearch) {\n"
                . "    try {\n"
                . "        \$__aggRow = \\PpPhp\\Core\\DB::Query(\"SELECT $aggSelect FROM `\$table` \$where\", \$params)->fetchAssoc() ?: [];\n"
                . $aggAssign
                . "    } catch (\\Throwable \$e) { \$aggregates = []; }\n"
                . "}";
            $this->log("Aggregate footer for $tableName (all records): " . implode(', ', array_map(fn($f, $m) => "$f={$m['fn']}", array_keys($aggCols), $aggCols)));
        } elseif ($showTotals) {
            $aggAssign = '';
            foreach ($aggCols as $f => $meta) {
                $aggAssign .= "\$aggregates['$f'] = ['fn' => '{$meta['fn']}', 'value' => pp_page_aggregate(\$records, '$f', '{$meta['fn']}'), "
                    . "'text' => '" . addslashes($meta['text']) . "', 'style' => '" . addslashes($meta['style']) . "'];\n";
            }
            $aggPhp = "\n\$aggregates = [];\nif (!\$hideUntilSearch && \$records !== []) {\n" . $aggAssign . "}";
            $this->log("Aggregate footer for $tableName (displayed records): " . implode(', ', array_map(fn($f, $m) => "$f={$m['fn']}", array_keys($aggCols), $aggCols)));
        }

        // View As controls for cells: grid column viewControl wins, then the
        // legacy Fields-module viewAs, then a smart default from the DB type
        // (same mapping the Designer applies when creating columns).
        // Collects both View + Edit control assets for this list page.
        $listUsedControls = [];
        $viewAsMap = [];
        foreach ($listFields as $f) {
            // Designed grid-column control wins, then a PER-PAGE 'list' override,
            // then the field-level viewAs default, then a smart default from the type.
            $ctl = (string) ($f['viewCtl'] ?? '');
            $settings = (array) ($f['viewSettings'] ?? []);
            if ($ctl === '' || $ctl === 'PlainText') {
                $pp = $this->perPageControl($fieldsByName[$f['name']] ?? [], 'list');
                if ($pp !== null) {
                    $ctl = $pp['control']; $settings = $pp['settings'];
                } else {
                    $ctl = (string) ($fieldsByName[$f['name']]['viewAs'] ?? '');
                    $settings = (array) ($fieldsByName[$f['name']]['viewAsSettings'] ?? []);
                }
            }
            if ($ctl === '' || $ctl === 'PlainText') {
                $ctl = $this->typeViewControl((string) ($fieldsByName[$f['name']]['type'] ?? ''), (string) $f['name']);
                $settings = [];
            }
            if ($ctl !== '' && $ctl !== 'PlainText') {
                $viewAsMap[$f['name']] = ['control' => $ctl, 'settings' => $settings];
                // View controls contribute their assets too (e.g. Image/File →
                // pp-lightbox) so the gallery/preview works on the list page.
                $listUsedControls[$ctl] = true;
            }
        }
        $viewAsExport = var_export($viewAsMap, true);

        // $fields emitted into the page come from the designed columns
        $fields = $listFields;

        // Lookup (foreign-key) fields configured on this table's Add/Edit forms:
        // drive display mapping, filter selects, bulk-update selects, inline edit.
        $lookupFields = $this->lookupFieldsFor($tableName);
        // Static value→label maps for choice/toggle controls (Dropdown, Switch…).
        $optionMaps = $this->optionMapsFor($tableName);

        // Fields with a designed filter control (dropdown) filter by EXACT
        // match; free-text filters use LIKE contains. Computed after $fields
        // is the final designed-column list. Lookup fields also filter by
        // exact key (their filter input is a dropdown of keys).
        $exactFilterFields = [];
        foreach ($fields as $f) {
            $fc = (string) ($f['filterCtl'] ?? '');
            if ($fc !== '' && $fc !== 'TextFilter') $exactFilterFields[] = $f['name'];
        }
        foreach (array_keys($lookupFields) as $lname) {
            if (!in_array($lname, $exactFilterFields, true)) $exactFilterFields[] = $lname;
        }
        // Multi-value (CSV) lookup fields filter with FIND_IN_SET, not equality.
        $csvFilterFields = [];
        foreach ($lookupFields as $lname => $lkDef) {
            if (!empty($lkDef['multiple'])) $csvFilterFields[] = $lname;
        }
        $exactFilterFields = array_values(array_diff($exactFilterFields, $csvFilterFields));
        $csvFiltersStr = '[' . implode(', ', array_map(fn($n) => var_export($n, true), $csvFilterFields)) . ']';
        $exactFiltersStr = '[' . implode(', ', array_map(fn($n) => var_export($n, true), $exactFilterFields)) . ']';
        // Per-field free-text match mode (contains/starts/ends/exact), set on the
        // TextFilter control via the field-controls modal. Absent = 'contains'.
        $filterModes = [];
        foreach ($fieldsByName as $__fn => $__fdef) {
            if ((string) ($__fdef['filterAs'] ?? '') !== 'TextFilter') continue;
            $__m = (string) (((array) ($__fdef['filterAsSettings'] ?? []))['mode'] ?? '');
            if (in_array($__m, ['starts', 'ends', 'exact'], true)) $filterModes[(string) $__fn] = $__m;
        }
        $filterModesStr = '[' . implode(', ', array_map(
            fn($k, $v) => var_export($k, true) . ' => ' . var_export($v, true),
            array_keys($filterModes), array_values($filterModes)
        )) . ']';
        // $fieldNamesStr uses the final designed columns (validSorts must
        // include filter columns for the filter WHERE to accept them).
        $fieldNamesStr = '[' . implode(', ', array_map(fn($f) => var_export($f['name'], true), $fields)) . ']';
        $showActions = ($grid['showActions'] ?? true) ? 'true' : 'false';
        // Selection checkboxes follow the designed grid (selection/selectionMode);
        // the Pages functionality flags gate the bulk actions, not the checkboxes.
        $selection = (($grid['selection'] ?? true) && (($grid['selectionMode'] ?? 'multiple') !== 'none')) ? 'true' : 'false';
        $selectionModeExport = var_export(($grid['selectionMode'] ?? 'multiple') === 'single' ? 'single' : 'multiple', true);
        $canDelete = ($fn['deleteRecords'] ?? true) ? 'true' : 'false';
        // Grid actions may be simple name strings ('view'|'edit'|'delete') OR the
        // richer row-action objects the default templates emit ({type,text,icon…}).
        // Extract the action NAME from either shape so the view/edit/delete row
        // buttons stay gated correctly (objects previously strval'd to "Array",
        // silently dropping View/Edit on never-opened-Designer builds).
        $designActions = array_values(array_filter(array_map(
            static fn($a): string => is_array($a) ? (string) ($a['type'] ?? $a['action'] ?? $a['name'] ?? '') : (string) $a,
            (array) ($grid['actions'] ?? ['view', 'edit', 'delete'])
        )));
        $canAdd  = in_array('add', $types, true);
        $canEdit = (in_array('edit', $types, true) && in_array('edit', $designActions, true)) ? 'true' : 'false';
        $canView = (in_array('view', $types, true) && in_array('view', $designActions, true)) ? 'true' : 'false';
        $canDelete = ($canDelete === 'true' && in_array('delete', $designActions, true)) ? 'true' : 'false';

        // Functionality flags from the Pages module
        $fnInlineEdit  = !empty($fn['inlineEdit']);
        $fnInlineAdd   = !empty($fn['inlineAdd']) && $canAdd;
        $fnCopyRecords = !empty($fn['copyRecords']) && $canAdd;
        $fnSpreadsheet = !empty($fn['spreadsheet']);
        // Spreadsheet editing starts LOCKED (read-only) on page load — the grid does
        // NOT carry pp-spreadsheet initially; the toolbar toggle adds it on demand.

        // Fields editable inline (grid columns whose base field allows editing).
        // The primary key is never inline-editable (real pk name, not literal 'id').
        $editableFields = [];
        $__iePk = $this->primaryKeyFor($tableName);
        foreach ($fields as $lf) {
            $base = $fieldsByName[$lf['name']] ?? [];
            if ($lf['name'] !== $__iePk && ($base['edit'] ?? true)) $editableFields[] = $lf['name'];
        }
        $editableFieldsExport = var_export($editableFields, true);

        // Tables module relations → per-row details links on this list page
        // (Relation modal: show details links, link style, display option,
        // child count badge, hide-if-no-records).
        $listRels = array_values(array_filter($this->relationsForMaster($tableName, 'list'),
            static fn ($r) => !empty($r['show_details_links'])));
        $detailsDefs = [];
        $singleLink = $listRels !== [] && (($listRels[0]['details_link_mode'] ?? 'single') === 'single');
        if ($singleLink && count($listRels) > 1) {
            $first = $listRels[0];
            $detailsDefs[] = [
                'rel' => implode(',', array_map(static fn ($r) => $r['id'], $listRels)),
                'label' => 'Details', 'single' => true,
                'mode' => (string) ($first['display_option'] ?? 'inline') ?: 'inline',
                'detail' => $first['detail_table'], 'fk' => $first['detail_field'], 'masterField' => $first['master_field'],
                'count' => !empty($first['display_child_count']),
                'countColor' => (string) ($first['count_badge_color'] ?? ''),
                'hideNoRecords' => !empty($first['hide_details_no_records']),
            ];
        } else {
            foreach ($listRels as $r) {
                $detailsDefs[] = [
                    'rel' => $r['id'],
                    'label' => ucwords(str_replace(['-', '_'], ' ', $r['detail_table'])),
                    'single' => $singleLink,
                    'mode' => (string) ($r['display_option'] ?? 'inline') ?: 'inline',
                    'detail' => $r['detail_table'], 'fk' => $r['detail_field'], 'masterField' => $r['master_field'],
                    'count' => !empty($r['display_child_count']),
                    'countColor' => (string) ($r['count_badge_color'] ?? ''),
                    'hideNoRecords' => !empty($r['hide_details_no_records']),
                ];
            }
        }
        // Designer overrides from the list design's table-grid
        // (props.detailsCol: header label/position/width/align, link + badge
        // styling, per-relation label/icon, hide-on-this-page).
        $dcCfg = (array) ($grid['detailsCol'] ?? []);
        if (!empty($dcCfg['hidden'])) $detailsDefs = [];
        $dcLinkCss = '';
        if (!empty($dcCfg['linkColor'])) $dcLinkCss .= 'color:' . $dcCfg['linkColor'] . ';';
        if (!empty($dcCfg['bold'])) $dcLinkCss .= 'font-weight:700;';
        if (($dcCfg['underline'] ?? true) === false) $dcLinkCss .= 'text-decoration:none;';
        foreach ($detailsDefs as &$dd) {
            $ov = (array) (($dcCfg['perRel'] ?? [])[$dd['rel']] ?? []);
            if (trim((string) ($ov['label'] ?? '')) !== '') $dd['label'] = (string) $ov['label'];
            $dd['icon'] = (string) (($ov['icon'] ?? '') !== '' ? $ov['icon'] : ($dcCfg['icon'] ?? ''));
            $dd['showIcon'] = ($dcCfg['showIcon'] ?? true) !== false;
            $dd['linkStyle'] = $dcLinkCss;
            $badgeMode = (string) ($dcCfg['badge'] ?? 'auto');
            if ($badgeMode === 'show') $dd['count'] = true;
            elseif ($badgeMode === 'hide') $dd['count'] = false;
            if (!empty($dcCfg['badgeBg'])) $dd['countColor'] = (string) $dcCfg['badgeBg'];
            $dd['badgeColor'] = (string) ($dcCfg['badgeColor'] ?? '');
            $dd['badgeSize'] = in_array($dcCfg['badgeSize'] ?? '', ['sm', 'md', 'lg'], true) ? $dcCfg['badgeSize'] : 'md';
            $dd['badgeShape'] = in_array($dcCfg['badgeShape'] ?? '', ['pill', 'rounded', 'square', 'circle'], true)
                ? $dcCfg['badgeShape'] : ((($dcCfg['badgePill'] ?? true) !== false) ? 'pill' : 'rounded');
            if (($ov['showLabel'] ?? true) === false) $dd['noLabel'] = true;
        }
        unset($dd);
        $dcThCss = '';
        if (!empty($dcCfg['width'])) $dcThCss .= 'width:' . preg_replace('/[^A-Za-z0-9.% ]/', '', (string) $dcCfg['width']) . ';';
        if (!empty($dcCfg['align']) && $dcCfg['align'] !== 'left') $dcThCss .= 'text-align:' . preg_replace('/[^a-z]/', '', (string) $dcCfg['align']) . ';';
        $detailsHeaderExport = var_export((string) ($dcCfg['label'] ?? ''), true);
        $detailsPositionExport = var_export((($dcCfg['position'] ?? 'start') === 'end') ? 'end' : 'start', true);
        $detailsStyleExport = var_export($dcThCss, true);
        $detailsAlignExport = var_export((string) (($dcCfg['align'] ?? '') !== 'left' ? ($dcCfg['align'] ?? '') : ''), true);
        $detailsExport = var_export($detailsDefs, true);
        $inlineMeta = ($fnInlineEdit || $fnInlineAdd || $fnSpreadsheet) ? 'true' : 'false';
        $inlineAddFlag = $fnInlineAdd ? 'true' : 'false';

        // Row actions resolved like the Designer does: base metadata overlaid
        // with the grid's actionConfig (icon, label, displayMode, danger, visible)
        $actionMeta = [
            'view'        => ['icon' => 'bi-eye', 'label' => 'View'],
            'edit'        => ['icon' => 'bi-pencil', 'label' => 'Edit'],
            'inline-edit' => ['icon' => 'bi-pencil-square', 'label' => 'Inline edit'],
            'copy'        => ['icon' => 'bi-files', 'label' => 'Copy'],
            'delete'      => ['icon' => 'bi-trash', 'label' => 'Delete', 'danger' => true],
        ];
        // Pages-module functionality adds its actions even when the design predates them
        if ($fnInlineEdit && !in_array('inline-edit', $designActions, true)) $designActions[] = 'inline-edit';
        if ($fnCopyRecords && !in_array('copy', $designActions, true)) $designActions[] = 'copy';

        $actionCfg = (array) ($grid['actionConfig'] ?? []);
        $actionItems = [];
        $skippedActions = [];
        foreach ($designActions as $key) {
            if (!isset($actionMeta[$key])) { $skippedActions[] = $key; continue; }
            $ov = (array) ($actionCfg[$key] ?? []);
            if (isset($ov['visible']) && !$ov['visible']) continue;
            if ($key === 'view' && $canView !== 'true') continue;
            if ($key === 'edit' && $canEdit !== 'true') continue;
            if ($key === 'delete' && $canDelete !== 'true') continue;
            if ($key === 'inline-edit' && !$fnInlineEdit) continue;
            if ($key === 'copy' && !$fnCopyRecords) continue;
            $actionItems[$key] = [
                'icon'    => (string) ($ov['icon'] ?? $actionMeta[$key]['icon']),
                'label'   => (string) ($ov['label'] ?? $actionMeta[$key]['label']),
                'danger'  => (bool) ($ov['danger'] ?? ($actionMeta[$key]['danger'] ?? false)),
                'display' => (string) ($ov['displayMode'] ?? 'icon'),
                'class'   => trim(preg_replace('/[^a-zA-Z0-9 _-]/', '', (string) ($ov['class'] ?? ''))),
            ];
        }
        if ($skippedActions !== []) {
            $this->log("grid actions not yet supported for $tableName: " . implode(', ', array_unique($skippedActions)));
        }
        $actionItemsExport = var_export($actionItems, true);
        // Breadcrumb — from the designed items when present, otherwise a sensible
        // default (Home › Table › current) so a never-designed page still gets one.
        // (A design that hides the breadcrumb still wins via the visibility gate below.)
        $crumbItems = array_values(array_filter(
            array_map(static fn($s) => trim((string) $s), (array) ($props['breadcrumb']['items'] ?? [])),
            static fn(string $s): bool => $s !== ''
        ));
        $crumbDefaulted = ($crumbItems === []);
        if ($crumbItems === []) $crumbItems = ['Home', $tableLabel];
        $parts = [];
        $lastIdx = count($crumbItems) - 1;
        foreach ($crumbItems as $i => $item) {
            $safe = addslashes(htmlspecialchars($item));
            if ($i === 0) {
                // The "Home" crumb is chrome → translatable (whether it came from the
                // default or a scaffold); any other custom first crumb stays literal.
                $homeExpr = ($crumbDefaulted || $item === 'Home')
                    ? 'htmlspecialchars(pp_t(\'ui.home\', \'Home\'))'
                    : "'$safe'";
                $parts[] = '\'<a href="\' . APP_BASE . \'/">\' . ' . $homeExpr . ' . \'</a>\'';
            } elseif ($i === $lastIdx) {
                $parts[] = '\'<span>\' . htmlspecialchars($__caption) . \'</span>\'';
            } else {
                $parts[] = "'<span>$safe</span>'";
            }
        }
        $crumbArg = '\'<nav class="pp-breadcrumb">\' . ' . implode(' . \'' . $this->crumbSeparator($props) . '\' . ', $parts) . ' . \'</nav>\'';
        // Add/Edit/View "Show in popup" settings — open the page in an iframe modal
        // (generic data-pp-form-modal trigger, wired by the list script).
        $addSettings  = $this->addSettingsFor($tableName);
        $editSettings = $this->editSettingsFor($tableName);
        $viewSettings = $this->viewSettingsFor($tableName);
        $anyPopup = $addSettings['showInPopup'] || $editSettings['showInPopup'] || $viewSettings['showInPopup'];
        $editPopupFlag = $editSettings['showInPopup'] ? 'true' : 'false';   // row edit → modal
        $viewPopupFlag = $viewSettings['showInPopup'] ? 'true' : 'false';   // row view → modal
        // Add-New button honours the Designer's per-control overrides (select the
        // button → Properties): label, variant (primary/danger/neutral), a custom
        // bi- icon, and display mode (icon / label / both).
        $addCfg = (array) (($props['list-toolbar']['config']['add']) ?? []);
        $addVariant = (string) ($addCfg['variant'] ?? 'primary');
        $addBtnStyle = $addVariant === 'danger'
            ? 'background:var(--pp-danger,#dc2626);color:#fff;border-color:var(--pp-danger,#dc2626)'
            : ($addVariant === 'default' ? ''
                : 'background:var(--pp-primary,#6366f1);color:var(--pp-primary-text,#fff);border-color:var(--pp-primary,#6366f1)');
        $addStyleAttr = $addBtnStyle !== '' ? ' style="' . $addBtnStyle . '"' : '';
        $addLabelCustom = trim((string) ($addCfg['label'] ?? ''));
        $addLabelExpr = $addLabelCustom !== '' ? htmlspecialchars($addLabelCustom) : '<?= pp_t(\'ui.add_new\', \'Add New\') ?>';
        $addIcon = preg_replace('/[^a-z0-9-]/i', '', (string) ($addCfg['icon'] ?? ''));
        $addIconTag = $addIcon !== '' ? '<i class="bi ' . $addIcon . '"></i>' : '+';
        $addDisplay = (string) ($addCfg['displayMode'] ?? 'icon_label');
        $addBtnInner = $addDisplay === 'icon' ? $addIconTag
            : ($addDisplay === 'label' ? $addLabelExpr : $addIconTag . ' ' . $addLabelExpr);
        $addButtonHtml = $canAdd
            ? (($addSettings['showInPopup'])
                ? '<button type="button" class="ppd-grid-toolbar-btn" data-pp-btn="add"' . $addStyleAttr . ' data-pp-form-modal="<?= APP_BASE ?>/generated/' . $tableName . '/add.php">' . $addBtnInner . '</button>'
                : '<a href="<?= APP_BASE ?>/generated/' . $tableName . '/add.php" class="ppd-grid-toolbar-btn" data-pp-btn="add"' . $addStyleAttr . '>' . $addBtnInner . '</a>')
            : '';
        // "Add the first record" CTA shown in the empty state (only when the user
        // may create and no filter/search is narrowing the view).
        $emptyAddCta = $canAdd
            ? '<a class="btn btn-sm btn-primary" href="<?= APP_BASE ?>/generated/' . $tableName . '/add.php"><i class="bi bi-plus-lg"></i> <?= htmlspecialchars(pp_t(\'ui.add_first\', \'Add the first record\')) ?></a>'
            : '';
        // Shared iframe modal shell — emitted whenever any of add/edit/view opens in a popup.
        $addModalHtml = $anyPopup
            ? '<div id="ppFormModal" class="pp-form-modal" hidden><div class="pp-form-modal-dialog"><div class="pp-form-modal-head"><span class="pp-form-modal-title">&nbsp;</span><button type="button" class="pp-form-modal-x" aria-label="Close">&times;</button></div><iframe class="pp-form-modal-frame" title="Record form"></iframe></div></div>'
            : '';

        // ── Designed list-toolbar: left / center / right widget zones ──
        $toolbar = (array) ($props['list-toolbar'] ?? []);
        // Search field design (glass|minimal|soft|outline|pill) → a variant class.
        $searchStyle = preg_replace('/[^a-z]/', '', strtolower((string) ($toolbar['searchStyle'] ?? 'glass'))) ?: 'glass';
        if (!in_array($searchStyle, ['glass', 'minimal', 'soft', 'outline', 'pill'], true)) $searchStyle = 'glass';
        // Manual search width (Designer → search Properties → Width, e.g. "220px",
        // "40%", "auto"). Sets a CSS var used by the desktop rule only; the mobile
        // two-row layout still flexes the search full-width. Sanitised to a safe
        // length token so it can't inject arbitrary CSS.
        $searchWidthRaw = trim((string) ($toolbar['searchWidth'] ?? ''));
        $searchWidthStyle = '';
        if ($searchWidthRaw !== '' && preg_match('/^(auto|[0-9]{1,4}(\.[0-9]+)?(px|%|rem|em|ch|vw))$/i', $searchWidthRaw)) {
            $searchWidthStyle = ' style="--pp-search-w:' . $searchWidthRaw . ';--pp-search-max:' . $searchWidthRaw . '"';
        }
        // Per-control overrides (Designer → select a control → Properties). $tbParts
        // resolves a button's icon / tooltip / label / display / variant from its
        // config so a relabelled/reskinned control reaches the generated app. Used
        // by the icon-button widgets below; $tbConfig also gates VISIBILITY.
        $tbConfig = (array) ($toolbar['config'] ?? []);
        $tbParts = static function (string $key, string $defIconClass, string $defTipExpr, string $defLabelExpr = '') use ($tbConfig): array {
            $c = (array) ($tbConfig[$key] ?? []);
            $ic = trim((string) ($c['icon'] ?? ''));
            if ($ic !== '') { $ic = preg_replace('/[^a-z0-9-]/i', '', $ic); if ($ic !== '' && strncmp($ic, 'bi-', 3) !== 0) $ic = 'bi-' . $ic; }
            $iconClass = $ic !== '' ? $ic : $defIconClass;
            $iconHtml = '<i class="bi ' . $iconClass . '"></i>';
            $ct = trim((string) ($c['tooltip'] ?? ''));
            $tipVal = $ct !== '' ? htmlspecialchars($ct, ENT_QUOTES) : $defTipExpr;
            $lbl = trim((string) ($c['label'] ?? ''));
            $labelExpr = $lbl !== '' ? htmlspecialchars($lbl) : $defLabelExpr;
            $display = in_array(($c['displayMode'] ?? ''), ['icon', 'label', 'icon_label'], true) ? (string) $c['displayMode'] : 'icon';
            $showLabel = $display !== 'icon' && $labelExpr !== '';
            $inner = ($display === 'label' && $showLabel)
                ? '<span class="pp-tb-txt">' . $labelExpr . '</span>'
                : ($showLabel ? $iconHtml . ' <span class="pp-tb-txt">' . $labelExpr . '</span>' : $iconHtml);
            return ['inner' => $inner, 'tip' => $tipVal, 'collapsible' => $showLabel ? ' pp-tb-collapsible' : ''];
        };
        $tbPageSizes = array_values(array_filter(array_map('intval', (array) ($toolbar['pageSizes'] ?? []))));
        // Misc → Table Settings options win; the designed toolbar pageSizes are
        // only used when Table Settings has none.
        if ($perPageOptions === [] && $tbPageSizes !== []) $perPageOptions = $tbPageSizes;
        $perPageOptionsStr = var_export($perPageOptions, true);
        // Record-info line: a designer-set template stays literal; the default is
        // translatable at runtime (ui.record_info, placeholders preserved).
        $recordInfoCustom = trim((string) ($toolbar['recordInfo'] ?? ''));
        // Empty OR the scaffolded default template → translatable at runtime.
        $recordInfoIsDefault = ($recordInfoCustom === '' || $recordInfoCustom === 'Displaying %from% – %to% of %total%');
        $recordInfoTplExpr = !$recordInfoIsDefault
            ? '"' . addslashes($recordInfoCustom) . '"'
            : "pp_t('ui.record_info', 'Displaying %from% – %to% of %total%')";

        // Search: ONE context-aware button next to the box. It reads "Search"
        // (submits) while the input holds a new/unsearched query, and flips to
        // "Clear" (resets to the unfiltered list) once the results match what's
        // typed — so the button is always useful and there's never a stray Clear
        // button. A compact inline "×" clears typed text, but is hidden whenever
        // the button itself is the Clear action (never two clears at once). When
        // results are filtered, the input + button get an active highlight.
        // pp-search-init.js (in the list IIFE) keeps the button in sync as you type.
        // Unified search: leading magnifier + input + a trailing accent SUBMIT
        // button INSIDE the field (animated on hover/focus). When a search is
        // active the submit swaps to a × clear. No separate "Search" text button.
        $searchWidget = '<form method="get" class="pp-search-form<?= $search !== \'\' ? \' is-searched\' : \'\' ?>" data-clear-url="<?= APP_BASE ?>/generated/' . $tableName . '/list.php">'
            // Keep active filters / where-tab / sort / page-size while searching
            // (the visible input supplies search; page resets to 1).
            . '<?= pp_state_hidden([\'search\', \'page\']) ?>'
            . '<span class="pp-search-box pp-search--' . $searchStyle . '<?= $search !== \'\' ? \' is-clear-mode\' : \'\' ?>"' . $searchWidthStyle . '>'
            . '<i class="bi bi-search pp-search-ico" aria-hidden="true"></i>'
            . '<input type="search" name="search" class="ppd-grid-search" data-active="<?= htmlspecialchars($search) ?>" ' . $searchPhAttr . ' value="<?= htmlspecialchars($search) ?>">'
            . '<a class="pp-search-clear" href="<?= APP_BASE ?>/generated/' . $tableName . '/list.php" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.clear\', \'Clear\')) ?>" aria-label="<?= htmlspecialchars(pp_t(\'ui.clear_search\', \'Clear search\')) ?>"><i class="bi bi-x-lg"></i></a>'
            . '<button type="submit" class="pp-search-go" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.search\', \'Search\')) ?>" aria-label="<?= htmlspecialchars(pp_t(\'ui.search\', \'Search\')) ?>"><i class="bi bi-arrow-right"></i></button>'
            . '</span>'
            . '</form>';
        $pageSizeWidget = '<?php if ($perPageOptions !== []): ?><form method="get" style="display:inline-flex">'
            // Preserve search + filters + sort + where-tab when the page size
            // changes (the select supplies perPage; page resets to 1).
            . '<?= pp_state_hidden([\'perPage\', \'page\']) ?>'
            . '<select name="perPage" onchange="this.form.submit()" class="ppd-grid-search" style="width:auto" title="<?= htmlspecialchars(pp_t(\'ui.records_per_page\', \'Records per page\')) ?>">'
            . '<?php foreach ($perPageOptions as $opt): ?><option value="<?= $opt ?>"<?= $opt === $perPage ? \' selected\' : \'\' ?>><?= $opt ?></option><?php endforeach; ?>'
            . '</select></form><?php endif; ?>';

        // Export = data formats only (CSV / Excel / Word). PDF is a dedicated
        // page-type with its own button + real generation (pdf.php), so it is no
        // longer offered here — that avoided a redundant second "PDF" entry.
        // Formats come from the Pages module (Export Settings) when the user set
        // them there — that is the explicit "what to build" choice and must win.
        // Otherwise fall back to the designed toolbar prop, then to CSV. Without
        // this the Pages selection was ignored entirely and a never-designed page
        // silently offered CSV only.
        // Labels and icons come from the format classes themselves, so a newly
        // registered format appears in this menu with no change here.
        $exportFormats = $this->allowedExportFormats($tableName, $toolbar);
        $importAllowed = $this->allowedImportFormats($tableName) !== [];

        // ── "Data" menu ───────────────────────────────────────────────────────
        // Entry point for BOTH transfer screens plus the one-click downloads the
        // toolbar has always offered. Every link is tagged data-pp-data-link so
        // the runtime can append the current row selection at click time (see
        // the script below), which is what lets the Export screen open straight
        // into "Selected records".
        $dataItems = '';
        if ($exportFormats !== []) {
            $dataItems .= '<a href="export.php?<?= htmlspecialchars(http_build_query($_GET)) ?>" data-pp-data-link>'
                . '<i class="bi bi-box-arrow-up-right" style="color:var(--pp-primary,#2563eb)"></i> <?= htmlspecialchars(pp_t(\'ui.export_records\', \'Export records…\')) ?></a>';
        }
        if ($importAllowed) {
            // No data-pp-data-link: import reads a file, so a row selection is
            // meaningless to it and must not be tacked onto the URL.
            $dataItems .= '<a href="import.php">'
                . '<i class="bi bi-box-arrow-in-down-left" style="color:var(--pp-success-text,#16a34a)"></i> <?= htmlspecialchars(pp_t(\'ui.import_records\', \'Import records…\')) ?></a>';
        }
        if ($exportFormats !== []) {
            $dataItems .= '<div class="pp-menu-sep"></div><div class="pp-menu-cap"><?= htmlspecialchars(pp_t(\'ui.quick_download\', \'Quick download\')) ?></div>';
            foreach ($exportFormats as $fmt) {
                $cls = \PpPhp\Core\DataOps\FormatRegistry::exports()[$fmt] ?? null;
                if ($cls === null) continue;
                [$fIcon, $fColor] = $cls::icon();
                $dataItems .= '<a href="export.php?<?= htmlspecialchars(http_build_query(array_merge($_GET, [\'format\' => \'' . $fmt . '\']))) ?>" data-pp-data-link>'
                    . '<i class="bi ' . htmlspecialchars($fIcon) . '" style="color:' . htmlspecialchars($fColor) . '"></i> '
                    . htmlspecialchars($cls::label()) . '</a>';
            }
        }
        // Both transfers denied → no button at all. (The endpoints enforce this
        // independently; the menu is never the security boundary.)
        $exportWidget = $dataItems === '' ? '' :
            '<details class="pp-export pp-cols-toggle"><summary class="ppd-grid-toolbar-btn pp-tb-collapsible" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.data\', \'Data\')) ?>"><i class="bi bi-arrow-down-up"></i> <span class="pp-tb-txt"><?= htmlspecialchars(pp_t(\'ui.data\', \'Data\')) ?></span> <i class="bi bi-chevron-down" style="font-size:.65rem"></i></summary>'
            . '<div class="pp-cols-menu pp-export-menu">' . $dataItems . '</div></details>';

        // Filter panel: per-column filters (submit as f[column]). A column with
        // a designed filter control (e.g. Lookup Dropdown Filter) renders that
        // control; otherwise a plain contains text input.
        // Only fields flagged "Show on Filter Panel" (Fields module → Search
        // Settings) appear here — the flag lives on the extracted field record
        // ($fieldsByName), not the designed column. If none are flagged, fall
        // back to all fields so projects without search settings keep a filter.
        $filterFields = array_values(array_filter($fields, static fn(array $f): bool => !empty($fieldsByName[$f['name']]['filterPanel'] ?? false)));
        if ($filterFields === []) $filterFields = $fields;
        $filterInputs = '';
        $setFilterCols = [];   // columns rendered as Set-Filter dropdowns (→ exact/IN, never LIKE)
        foreach ($filterFields as $ff) {
            $fn = addslashes($ff['name']);
            $fl = "<?= htmlspecialchars(pp_t('label.$tableName.$fn', '" . addslashes((string) ($ff['label'] ?? $ff['name'])) . "')) ?>";
            // Per-page designed filter control, else a PER-PAGE 'search' override,
            // else the field-level filterAs (set once via the field-controls modal).
            $filterCtl = (string) ($ff['filterCtl'] ?? '');
            $filterSet = (array) ($ff['filterSettings'] ?? []);
            if ($filterCtl === '' || $filterCtl === 'TextFilter') {
                $ppF = $this->perPageControl($fieldsByName[$ff['name']] ?? [], 'search');
                if ($ppF !== null) {
                    $filterCtl = $ppF['control']; $filterSet = $ppF['settings'];
                } else {
                    $fLevel = (string) ($fieldsByName[$ff['name']]['filterAs'] ?? '');
                    if ($fLevel !== '') { $filterCtl = $fLevel; $filterSet = (array) ($fieldsByName[$ff['name']]['filterAsSettings'] ?? []); }
                }
            }
            if ($filterCtl !== '' && $filterCtl !== 'TextFilter') {
                // Give the filter control its source table/column so a filter can
                // populate options from the column's DISTINCT values at runtime.
                $filterSet['__table'] = $tableName;
                $filterSet['__column'] = $ff['name'];
                $fset = var_export($filterSet, true);
                $ctlHtml = "<?= \\PpPhp\\Core\\Designer\\FieldControl\\FieldControlRuntime::render('filter', '"
                    . addslashes($filterCtl) . "', '{$ff['name']}', null, $fset)['html'] ?>";
                $filterInputs .= '<label><span>' . $fl . '</span>' . $ctlHtml . '</label>';
            } elseif (isset($lookupFields[$ff['name']])) {
                // Lookup field: filter by display name, submit the exact key.
                $filterInputs .= '<label><span>' . $fl . '</span><select name="f[' . $fn . ']" class="ppd-grid-search">'
                    . '<option value=""><?= pp_t(\'ui.all\', \'All\') ?></option>'
                    . '<?php foreach ((array) ($__ppLookups[\'' . $fn . '\'] ?? []) as $__k => $__d): ?>'
                    . '<option value="<?= htmlspecialchars((string) $__k) ?>"<?= (string) ($_GET[\'f\'][\'' . $fn . '\'] ?? \'\') === (string) $__k ? \' selected\' : \'\' ?>><?= htmlspecialchars($__d) ?></option>'
                    . '<?php endforeach; ?></select></label>';
            } else {
                // Field label routed through the Label Editor (pp_t) so the filter
                // popup title + range aria-labels follow the active language.
                $sfLabelAttr = "<?= htmlspecialchars(pp_t('label.$tableName.{$ff['name']}', '" . addslashes((string) ($ff['label'] ?? $ff['name'])) . "'), ENT_QUOTES) ?>";
                $__ft = strtolower((string) ($ff['type'] ?? 'text'));
                $__isNum  = in_array($__ft, ['number', 'int', 'integer', 'bigint', 'mediumint', 'smallint', 'tinyint', 'decimal', 'double', 'float', 'currency', 'money'], true);
                $__isDate = in_array($__ft, ['date', 'datetime', 'datetime-local', 'timestamp'], true);
                if ($__isNum || $__isDate) {
                    // Min–max RANGE. Numbers → min/max (>= / <=); dates → from/to
                    // (whole-day inclusive BETWEEN). Applied via the panel's Apply.
                    $__k1 = $__isDate ? 'from' : 'min';
                    $__k2 = $__isDate ? 'to' : 'max';
                    $__it = $__isDate ? 'date' : 'number';
                    $__cls = $__isDate ? 'pp-daterange' : 'pp-numrange';
                    $__sep = $__isDate ? 'pp-daterange-sep' : 'pp-numrange-sep';
                    $__loPh = $__isDate ? "<?= htmlspecialchars(pp_t('ui.from', 'From'), ENT_QUOTES) ?>" : "<?= htmlspecialchars(pp_t('ui.min', 'Min'), ENT_QUOTES) ?>";
                    $__hiPh = $__isDate ? "<?= htmlspecialchars(pp_t('ui.to', 'To'), ENT_QUOTES) ?>" : "<?= htmlspecialchars(pp_t('ui.max', 'Max'), ENT_QUOTES) ?>";
                    $filterInputs .= '<label><span>' . $fl . '</span><div class="' . $__cls . '">'
                        . '<input type="' . $__it . '" name="f[' . $fn . '][' . $__k1 . ']" value="<?= htmlspecialchars((string) ($_GET[\'f\'][\'' . $fn . '\'][\'' . $__k1 . '\'] ?? \'\')) ?>" placeholder="' . $__loPh . '" class="ppd-grid-search" aria-label="' . $sfLabelAttr . ' ' . $__loPh . '">'
                        . '<span class="' . $__sep . '">–</span>'
                        . '<input type="' . $__it . '" name="f[' . $fn . '][' . $__k2 . ']" value="<?= htmlspecialchars((string) ($_GET[\'f\'][\'' . $fn . '\'][\'' . $__k2 . '\'] ?? \'\')) ?>" placeholder="' . $__hiPh . '" class="ppd-grid-search" aria-label="' . $sfLabelAttr . ' ' . $__hiPh . '">'
                        . '</div></label>';
                } else {
                    // Best-in-class default: a Set-Filter dropdown (distinct values via
                    // api/filter-values.php — searchable, multi-select, counts) instead of
                    // a blind text box. Selections submit f[col][] → WHERE col IN (…),
                    // EXACT — fixing the "gender=Male also matched Female" LIKE bug.
                    // `data-sf-defer` → the popup writes hidden inputs to the panel form
                    // so the single Apply button commits every column together.
                    $setFilterCols[] = (string) $ff['name'];
                    $filterInputs .= '<label><span>' . $fl . '</span>'
                        . '<?php $__sfv = (array) ($_GET[\'f\'][\'' . $fn . '\'] ?? []); $__sfv = array_values(array_filter(array_map(\'strval\', $__sfv), \'strlen\')); ?>'
                        . '<button type="button" class="pp-sf-trigger<?= $__sfv ? \' is-active\' : \'\' ?>" data-pp-setfilter data-sf-defer'
                        . ' data-table="' . htmlspecialchars($tableName, ENT_QUOTES) . '"'
                        . ' data-column="' . htmlspecialchars((string) $ff['name'], ENT_QUOTES) . '"'
                        . ' data-label="' . $sfLabelAttr . '"'
                        . ' data-all-label="<?= htmlspecialchars(pp_t(\'ui.all\', \'All\'), ENT_QUOTES) ?>"'
                        . ' data-sel-label="<?= htmlspecialchars(pp_t(\'ui.selected\', \'selected\'), ENT_QUOTES) ?>"'
                        . ' aria-haspopup="listbox" aria-expanded="false">'
                        . '<span class="pp-sf-tval"><?= $__sfv === [] ? htmlspecialchars(pp_t(\'ui.all\', \'All\')) : htmlspecialchars(count($__sfv) > 2 ? (count($__sfv) . \' \' . pp_t(\'ui.selected\', \'selected\')) : implode(\', \', $__sfv)) ?></span>'
                        . '<i class="bi bi-chevron-down"></i></button></label>';
                }
            }
        }
        // Every Set-Filter column (toolbar dropdowns + header funnels) matches
        // EXACTLY — a single scalar value must never fall through to LIKE (the
        // gender=Male ⊂ Female bug). Merge them into the exact-filter list and
        // rebuild the string the list page reads (used later, at $exactFiltersStr).
        $exactFilterFields = array_values(array_diff(
            array_unique(array_merge($exactFilterFields, $setFilterCols, $headerFilterCols)),
            $csvFilterFields
        ));
        $exactFiltersStr = '[' . implode(', ', array_map(fn($n) => var_export($n, true), $exactFilterFields)) . ']';
        // Ship the shared Set-Filter popup script only when the page actually has a
        // Set-Filter surface: toolbar dropdowns, per-column header funnels, or the
        // grid-level "Filter row" master toggle (funnels on every column).
        $setFilterScript = ($setFilterCols !== [] || $headerFilterCols !== [] || $gridHeaderFiltersOn) ? $this->setFilterScript() : '';
        $filterWidget = '<details class="pp-filter pp-cols-toggle"><summary class="ppd-grid-toolbar-btn pp-tb-collapsible" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.filter\', \'Filter\')) ?>"><i class="bi bi-funnel"></i> <span class="pp-tb-txt"><?= htmlspecialchars(pp_t(\'ui.filter\', \'Filter\')) ?></span></summary>'
            . '<div class="pp-cols-menu pp-filter-menu"><form method="get">'
            . '<div class="pp-filter-title"><i class="bi bi-funnel"></i> <?= htmlspecialchars(pp_t(\'ui.filter_records\', \'Filter records\')) ?></div>'
            // Preserve search / sort / where-tab / page-size while applying filters
            // (this form supplies every f[]; page resets to 1).
            . '<?= pp_state_hidden([\'f\', \'page\']) ?>'
            . $filterInputs
            . '<div class="pp-filter-actions"><a class="pp-filter-reset" href="list.php"><i class="bi bi-arrow-counterclockwise"></i> <?= htmlspecialchars(pp_t(\'ui.reset\', \'Reset\')) ?></a>'
            . '<button type="submit" class="pp-filter-apply"><i class="bi bi-check2"></i> <?= htmlspecialchars(pp_t(\'ui.apply\', \'Apply\')) ?></button></div>'
            . '</form></div></details>';

        // Resolve each utility button's icon/tooltip/label from its Designer config
        // override (falls back to the translatable defaults when nothing is set).
        $prP = $tbParts('print', 'bi-printer', '<?= htmlspecialchars(pp_t(\'ui.print\', \'Print\')) ?>');
        $pdP = $tbParts('pdf', 'bi-file-earmark-pdf-fill', '<?= htmlspecialchars(pp_t(\'ui.download_pdf\', \'Download as PDF\')) ?>');
        $rfP = $tbParts('refresh', 'bi-arrow-clockwise', '<?= htmlspecialchars(pp_t(\'ui.refresh\', \'Refresh\')) ?>');
        $dnP = $tbParts('density_toggle', 'bi-arrows-vertical', '<?= htmlspecialchars(pp_t(\'ui.density\', \'Density\')) ?>');
        $clP = $tbParts('columns_toggle', 'bi-layout-three-columns', '<?= htmlspecialchars(pp_t(\'ui.columns\', \'Columns\')) ?>');
        $iaP = $tbParts('inline_add', 'bi-plus-square', '<?= htmlspecialchars(pp_t(\'ui.inline_add\', \'Inline Add\')) ?>');

        $widgets = [
            'add'            => $addButtonHtml,
            // Saved-views menu is injected by the list runtime JS (into the left
            // zone) when the savedViews setting is on — so the slot is a known,
            // empty widget here: reserved in the toolbar model + Designer canvas,
            // rendered by JS at runtime, and never flagged "unsupported".
            'views'          => '',
            'search'         => $searchWidget,
            'filter'         => $filterWidget,
            // The historic 'export' slot now renders the Data menu, so every
            // existing design picks it up with no edit; 'transfer' is an alias
            // for designs that want to name it explicitly.
            'export'         => $exportWidget,
            'transfer'       => $exportWidget,
            // Print is another full-data egress path, so it follows its page type
            // (same rule as pdf/export) instead of always being offered.
            'print'          => in_array('print', $types, true)
                ? '<a target="_blank" class="ppd-grid-toolbar-btn' . $prP['collapsible'] . '" data-pp-ov="2" data-pp-tip="' . $prP['tip'] . '" aria-label="' . $prP['tip'] . '" href="print.php?<?= htmlspecialchars(http_build_query($_GET)) ?>">' . $prP['inner'] . '</a>'
                : '',
            // PDF page-type: opens the (already print-optimized) print view with
            // auto-print on, so the browser's Save-as-PDF dialog appears at once.
            'pdf'            => in_array('pdf', $types, true)
                ? '<a target="_blank" class="ppd-grid-toolbar-btn' . $pdP['collapsible'] . '" data-pp-ov="1" data-pp-tip="' . $pdP['tip'] . '" aria-label="' . $pdP['tip'] . '" href="pdf.php?<?= htmlspecialchars(http_build_query($_GET)) ?>">' . $pdP['inner'] . '</a>'
                : '',
            // Cards page-type: a Table/Cards view switch (client-side, from the
            // rendered grid — see ppInitCardsView in app.js).
            'view_toggle'    => in_array('cards', $types, true)
                ? '<div class="ppd-view-toggle" role="group" aria-label="<?= htmlspecialchars(pp_t(\'ui.view_mode\', \'View mode\')) ?>">'
                    . '<button type="button" class="ppd-grid-toolbar-btn is-active" data-pp-view="table" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.table_view\', \'Table view\')) ?>" aria-label="<?= htmlspecialchars(pp_t(\'ui.table_view\', \'Table view\')) ?>"><i class="bi bi-table"></i></button>'
                    . '<button type="button" class="ppd-grid-toolbar-btn" data-pp-view="cards" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.card_view\', \'Card view\')) ?>" aria-label="<?= htmlspecialchars(pp_t(\'ui.card_view\', \'Card view\')) ?>"><i class="bi bi-grid-3x3-gap"></i></button>'
                    . '</div>'
                : '',
            'page_size'      => $pageSizeWidget,
            'record_info'    => '<span class="pp-record-info" role="status" aria-live="polite"><i class="bi bi-eye pp-ri-eye" aria-hidden="true"></i><span class="pp-ri-full"><?= htmlspecialchars($recordInfo) ?></span><span class="pp-ri-short" data-pp-tip="<?= htmlspecialchars($recordInfo) ?>"><?= (int) $__from ?>–<?= (int) $__to ?> / <?= (int) $total ?></span></span>',
            'refresh'        => '<button type="button" class="ppd-grid-toolbar-btn' . $rfP['collapsible'] . '" data-pp-ov="4" data-pp-tip="' . $rfP['tip'] . '" data-pp-tip-pos="right" aria-label="' . $rfP['tip'] . '" onclick="location.reload()">' . $rfP['inner'] . '</button>',
            'density_toggle' => '<button type="button" class="ppd-grid-toolbar-btn' . $dnP['collapsible'] . '" data-pp-ov="3" data-pp-tip="' . $dnP['tip'] . '" data-pp-tip-pos="right" aria-label="' . $dnP['tip'] . '" onclick="window.ppCycleDensity&&window.ppCycleDensity()">' . $dnP['inner'] . '</button>',
            'columns_toggle' => '<details class="pp-cols-toggle"><summary class="ppd-grid-toolbar-btn' . $clP['collapsible'] . '" data-pp-tip="' . $clP['tip'] . '" data-pp-tip-pos="right" aria-label="' . $clP['tip'] . '">' . $clP['inner'] . '</summary><div class="pp-cols-menu" id="ppColsMenu"></div></details>',
            'inline_add'     => $fnInlineAdd
                ? '<button type="button" class="ppd-grid-toolbar-btn' . $iaP['collapsible'] . '" id="ppInlineAdd" data-pp-tip="' . $iaP['tip'] . '" aria-label="' . $iaP['tip'] . '">' . $iaP['inner'] . '</button>'
                : '',
            // Spreadsheet toggle: the grid ships with the pp-spreadsheet class on
            // (cells editable on click), so the button starts active and toggling it
            // switches back to read-only view — real button parity with the Designer.
            'spreadsheet'    => $fnSpreadsheet
                ? '<button type="button" class="ppd-grid-toolbar-btn" id="ppSpreadsheetToggle" aria-pressed="false" data-pp-tip="Spreadsheet: cells locked (click to edit)" aria-label="Toggle spreadsheet editing" onclick="var t=document.querySelector(\'.ppd-grid-table\');if(!t)return;var on=t.classList.toggle(\'pp-spreadsheet\');this.classList.toggle(\'is-active\',on);this.setAttribute(\'aria-pressed\',on?\'true\':\'false\');var ic=this.querySelector(\'i\');if(ic)ic.className=on?\'bi bi-unlock-fill\':\'bi bi-lock-fill\';this.setAttribute(\'data-pp-tip\',on?\'Spreadsheet: cells editable (click to lock)\':\'Spreadsheet: cells locked (click to edit)\');"><i class="bi bi-lock-fill"></i></button>'
                : '',
            // Placeholder slot from older default layouts — known but renders
            // nothing (no runtime meaning), so it is silently dropped rather than
            // logged as an unsupported widget. Removed from new default zones.
            'custom'         => '',
        ];
        $unsupportedWidgets = [];
        // "Visible": a control the developer hid in the Designer is omitted from the
        // generated toolbar. ($tbConfig + $tbParts defined above with the widgets.)
        $ctrlHidden = static fn (string $key): bool => (($tbConfig[$key]['visible'] ?? true) === false);
        $zoneHtml = function (array $keys) use ($widgets, &$unsupportedWidgets, $ctrlHidden): string {
            $out = [];
            foreach ($keys as $key) {
                $key = (string) $key;
                if ($ctrlHidden($key)) continue;
                if (isset($widgets[$key])) {
                    if ($widgets[$key] !== '') $out[] = $widgets[$key];
                } else {
                    $unsupportedWidgets[] = $key;
                }
            }
            return implode("\n", $out);
        };
        // Default toolbar zones — mirror the Designer's default List template
        // (model.js buildListMaster) so a DIRECT build (developer never opened
        // the Designer, so no saved design) still ships the full, polished
        // toolbar instead of a bare Add + Search. A real design overrides these.
        $defCenter = ['search', 'filter'];
        $defRight  = ['record_info', 'export', 'page_size', 'print', 'refresh', 'density_toggle', 'columns_toggle'];
        // Inline Add sits next to Add New when enabled in the Pages module and
        // the designer hasn't placed the widget explicitly in any zone
        $leftKeys = (array) ($toolbar['left'] ?? ['add']);
        $centerKeys = (array) ($toolbar['center'] ?? $defCenter);
        $allZoneKeys = array_merge($leftKeys, $centerKeys, (array) ($toolbar['right'] ?? $defRight));
        if ($fnInlineAdd && !in_array('inline_add', array_map('strval', $allZoneKeys), true)) {
            $addPos = array_search('add', $leftKeys, true);
            array_splice($leftKeys, $addPos === false ? count($leftKeys) : $addPos + 1, 0, ['inline_add']);
        }
        // Spreadsheet toggle sits with Add/Inline Add when enabled and the designer
        // hasn't placed it explicitly (direct build without an opened Designer).
        if ($fnSpreadsheet && !in_array('spreadsheet', array_map('strval', $allZoneKeys), true)) {
            $iaPos = array_search('inline_add', $leftKeys, true);
            $refPos = $iaPos !== false ? $iaPos : array_search('add', $leftKeys, true);
            array_splice($leftKeys, $refPos === false ? count($leftKeys) : $refPos + 1, 0, ['spreadsheet']);
        }
        // The Table/Cards/Board view switch ALWAYS sits right AFTER the Filter button
        // (end of the centre zone), grouped with search + filter as the "find / view"
        // cluster — wherever a design happened to place it. This keeps the mobile
        // second row to pure action icons. Strip it from every zone first.
        $stripVT = static fn (array $k): array => array_values(array_filter($k, static fn ($x): bool => (string) $x !== 'view_toggle'));
        $leftKeys = $stripVT($leftKeys);
        $centerKeys = $stripVT($centerKeys);
        $tbLeft   = $zoneHtml($leftKeys);
        $tbCenter = $zoneHtml($centerKeys);
        if ($widgets['view_toggle'] !== '' && !$ctrlHidden('view_toggle')) {
            $tbCenter .= "\n" . '<span class="pp-tb-viewsep" aria-hidden="true"></span>' . $widgets['view_toggle'];
        }

        // Right zone: export/print lead, record_info + page_size stay adjacent
        $rightKeys = $stripVT(array_map('strval', (array) ($toolbar['right'] ?? $defRight)));
        // Auto-place the PDF button (Pages module) when enabled and the designer
        // hasn't positioned it explicitly in any zone.
        $autoRight = [];
        if ($widgets['pdf'] !== '' && !in_array('pdf', array_map('strval', $allZoneKeys), true)) $autoRight[] = 'pdf';
        if ($autoRight !== []) $rightKeys = array_merge($autoRight, $rightKeys);
        $lead = array_values(array_intersect(['export', 'print', 'pdf'], $rightKeys));
        $rest = array_values(array_diff($rightKeys, $lead));
        if (in_array('record_info', $rest, true) && in_array('page_size', $rest, true)) {
            $rest = array_values(array_diff($rest, ['page_size']));
            array_splice($rest, (int) array_search('record_info', $rest, true) + 1, 0, ['page_size']);
        }
        $orderedRight = array_merge($lead, $rest);
        $tbRight = '';
        foreach ($orderedRight as $key) {
            if ($ctrlHidden($key)) continue;
            if (!isset($widgets[$key])) { $unsupportedWidgets[] = $key; continue; }
            if ($key === 'record_info' && in_array('page_size', $rest, true)) {
                $tbRight .= '<span class="pp-tb-group">' . $widgets['record_info'] . "\n" . $widgets['page_size'] . '</span>' . "\n";
            } elseif ($key === 'page_size' && in_array('record_info', $rest, true)) {
                continue; // already rendered inside the group
            } else {
                $tbRight .= $widgets[$key] . "\n";
            }
        }
        // Overflow menu ("⋯ More"): starts hidden; the toolbar auto-fit JS moves the
        // lowest-priority actions (data-pp-ov) in here to guarantee a single desktop
        // row at any width. Hidden entirely in the ≤992px two-row layout.
        $tbRight .= '<details class="pp-tb-more pp-cols-toggle" hidden><summary class="ppd-grid-toolbar-btn" data-pp-tip="<?= htmlspecialchars(pp_t(\'ui.more\', \'More\')) ?>" data-pp-tip-pos="right" aria-label="<?= htmlspecialchars(pp_t(\'ui.more\', \'More\')) ?>"><i class="bi bi-three-dots"></i></summary><div class="pp-more-menu"></div></details>' . "\n";
        if ($unsupportedWidgets !== []) {
            $this->log("list-toolbar widgets not yet supported for $tableName: " . implode(', ', array_unique($unsupportedWidgets)));
        }

        // Loading overlay (Misc → per-table)
        $loadingBoxHtml = !empty($misc['loadingBox'])
            ? '<div class="pp-loading" id="ppLoading"><div class="pp-spinner"></div></div>' . "\n"
              . '<script>document.addEventListener("submit",function(){var l=document.getElementById("ppLoading");if(l)l.classList.add("show")});window.addEventListener("pagehide",function(){var l=document.getElementById("ppLoading");if(l)l.classList.remove("show")});</script>'
            : '';

        // Cards view (Pages module 'cards' type) — server-rendered from the card
        // template (settings.cards) so values match the grid (view controls,
        // lookups, formatting). Rendered hidden; the Table/Cards toggle reveals
        // it. Empty when the page-type isn't enabled. The client-side scraper in
        // app.js stays as a fallback for any empty (non-server) #ppCards mount.
        $cardTmplExport = var_export($this->cardTemplateFor($tableName, $fields), true);
        $cardsMount = in_array('cards', $types, true)
            ? "<?= renderCards(\$records, \$fields, '$tableName', $cardTmplExport, ['viewAs' => $viewAsExport, 'actions' => ['view' => $canView, 'edit' => $canEdit, 'delete' => $canDelete, 'show' => $showActions, 'items' => $actionItemsExport, 'editPopup' => $editPopupFlag, 'viewPopup' => $viewPopupFlag]]) ?>"
            : '';

        // Empty-state text (designed) + pagination summary toggle + breadcrumb separator
        $emptyTextDefault = addslashes(trim((string) ($grid['emptyText'] ?? '')) ?: 'No records found.');
        $pagSummary = (($props['pagination']['showSummary'] ?? true) !== false) ? 'true' : 'false';

        // Assemble the page style block (component chrome CSS + per-part CSS + page CSS)
        $allCss = trim(implode("\n", array_merge($collectedCss, $pageCss !== '' ? [$pageCss] : [])));
        $pageStyleBlock = $allCss !== '' ? "<style>\n$allCss\n</style>" : '';

        // Selection bar (designed) — count text + clear + gated bulk actions
        $selBar = (array) ($props['selection-bar'] ?? []);
        $selCountTpl = addslashes((string) ($selBar['countText'] ?? '%n% selected'));
        $selActions = array_map('strval', (array) ($selBar['actions'] ?? ['delete_selected']));
        $selHasDelete = in_array('delete_selected', $selActions, true);
        $selHasUpdate = (in_array('update_selected', $selActions, true) && ($fn['updateSelected'] ?? true)) ? 'true' : 'false';
        // "Export selected" is still an export — it obeys the same gate, and
        // downloads in the first ALLOWED format rather than a hardcoded CSV
        // (CSV may well be switched off).
        $selHasExport = (in_array('export_selected', $selActions, true) && $exportFormats !== []) ? 'true' : 'false';
        $selExportFormat = $exportFormats[0] ?? 'csv';
        $selShowClear = ($selBar['showClear'] ?? true);

        // Update-selected panel: editable fields (blank = keep current value)
        $updFields = '';
        $__pkName = $this->primaryKeyFor($tableName);
        foreach ($fieldsByName as $uf) {
            if (($uf['name'] ?? '') === $__pkName || !($uf['edit'] ?? true)) continue;
            $un = addslashes($uf['name']);
            // Bulk-update label routed through the Label Editor (pp_t) so the panel
            // follows the active language like every other field label.
            $ul = "<?= htmlspecialchars(pp_t('label.$tableName.{$uf['name']}', '" . addslashes((string) ($uf['label'] ?? $uf['name'])) . "'), ENT_QUOTES) ?>";
            if (isset($lookupFields[$uf['name']]) && empty($lookupFields[$uf['name']]['multiple'])) {
                // Lookup field: bulk-set by display name (submits the key).
                // (Multi-value lookups keep a text input — a single select
                // would overwrite the stored CSV.)
                $updFields .= '<label><span>' . $ul . '</span><select data-upd-field="' . $un . '" class="ppd-grid-search">'
                    . '<option value="">— <?= pp_t(\'ui.keep_current\', \'keep current\') ?> —</option>'
                    . '<?php foreach ((array) ($__ppLookups[\'' . $un . '\'] ?? []) as $__k => $__d): ?>'
                    . '<option value="<?= htmlspecialchars((string) $__k) ?>"><?= htmlspecialchars($__d) ?></option>'
                    . '<?php endforeach; ?></select></label>';
                continue;
            }
            $updFields .= '<label><span>' . $ul . '</span><input type="text" data-upd-field="' . $un . '" class="ppd-grid-search"></label>';
        }

        // Pagination position (designed)
        $pagPosition = (string) (($props['pagination']['position'] ?? 'bottom'));
        $pagTop    = in_array($pagPosition, ['top', 'both'], true) ? 'true' : 'false';
        $pagBottom = $pagPosition !== 'top' ? 'true' : 'false';

        // Buttons component (designed) — known button ids
        $buttonsHtml = '';
        foreach ((array) (($props['buttons']['buttons'] ?? [])) as $btn) {
            $btn = is_array($btn) ? (string) ($btn['id'] ?? $btn['action'] ?? '') : (string) $btn;
            $buttonsHtml .= match ($btn) {
                'home' => '<a href="<?= APP_BASE ?>/" class="ppd-grid-toolbar-btn"><i class="bi bi-house"></i> <?= pp_t(\'ui.back_home\', \'Back to Home\') ?></a>' . "\n",
                'back' => '<button type="button" class="ppd-grid-toolbar-btn" onclick="history.back()"><i class="bi bi-arrow-left"></i> Back</button>' . "\n",
                'add'  => $addButtonHtml . "\n",
                default => '',
            };
        }
        if ($buttonsHtml !== '') {
            $buttonsHtml = $chrome['buttons']['visible']
                ? '<div' . $attrOf($chrome['buttons'], 'pp-page-buttons') . '>' . "\n" . $buttonsHtml . '</div>'
                : '';
        }

        // Component visibility + chrome application. Each top-level component is a
        // .pp-grid-item in the .pp-zone12 body grid so it honours its per-device span.
        $toolbarOpen = $chrome['list-toolbar']['visible']
            ? '<div' . $itemAttr('list-toolbar', 'ppd-grid-toolbar pp-toolbar') . '>'
            : '<div style="display:none">';
        $toolbarClose = '</div>';
        // Selection bar keeps its initial display:none (JS reveals it); the span vars
        // ride alongside so a resized selection bar honours its width when shown.
        $selSpanVars = $itemSpanVars('selection-bar');
        $selbarChromeStyle = trim('display:none;' . $chrome['selection-bar']['style'] . ';' . $selSpanVars, ';');
        $selbarOpen = '<div class="' . trim('pp-grid-item ppd-grid-bulk-actions pp-selbar ' . $chrome['selection-bar']['class']) . '" id="bulkActions" style="' . htmlspecialchars($selbarChromeStyle) . '">';
        if (!$chrome['selection-bar']['visible']) $selection = 'false';
        if (!$chrome['pagination']['visible']) { $pagTop = 'false'; $pagBottom = 'false'; }
        if (!$chrome['breadcrumb']['visible']) $crumbArg = "''";
        elseif ($crumbArg !== "''" && ($chrome['breadcrumb']['class'] !== '' || $chrome['breadcrumb']['style'] !== '')) {
            $crumbArg = str_replace(
                '<nav class="pp-breadcrumb">',
                '<nav class="' . trim('pp-breadcrumb ' . $chrome['breadcrumb']['class']) . '"' . ($chrome['breadcrumb']['style'] !== '' ? ' style="' . htmlspecialchars($chrome['breadcrumb']['style']) . '"' : '') . '>',
                $crumbArg
            );
        }
        $pagOpts = "['class' => '" . addslashes($chrome['pagination']['class']) . "', 'style' => '" . addslashes($chrome['pagination']['style']) . "', 'summary' => $pagSummary]";
        // Scroll table data (fixed header) → bounded scroll region + sticky thead.
        $gridWrapClass = trim('pp-grid-item ' . addslashes($chrome['table-grid']['class']) . ($lsScroll ? ' pp-scroll-table' : ''));
        $gridWrapStyle = trim(addslashes($chrome['table-grid']['style']) . ';' . $itemSpanVars('table-grid'), ';');
        $chipsAttr     = $itemAttr('filter-chips', 'pp-filter-chips');   // active-filter chips as a grid item

        $listPublicReadLit = $this->publicReadFor($tableName) ? 'true' : 'false';   // A8

        // Real primary key (an imported table may not use `id`). Baked into the
        // sort default + row-link normalisation; a no-op for `id` tables.
        $pk = $this->primaryKeyFor($tableName);
        $pkIdNorm = ($pk !== 'id')
            ? "        foreach (\$records as &\$__pr) { if (!array_key_exists('id', \$__pr)) \$__pr['id'] = \$__pr['$pk'] ?? null; } unset(\$__pr);\n"
            : '';

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';

\$table = '$tableName';
\PpPhp\Core\Auth\PageAccess::guard('list', '$tableName', $listPublicReadLit);
\PpPhp\Core\Events::fire('tables.$tableName.page.beforeSearch', ['params' => &\$_GET]);
\$page = max(1, (int) (\$_GET['page'] ?? 1));
\$perPage = $perPage;
\$perPageOptions = $perPageOptionsStr;
if (\$perPageOptions !== [] && in_array((int) (\$_GET['perPage'] ?? 0), \$perPageOptions, true)) {
    \$perPage = (int) \$_GET['perPage'];
}
// (string) casts so an array-valued param (?search[]=x, ?dir[]=x, ?sort[]=x)
// can't fatal trim()/strtoupper() with a TypeError before the try/catch below
// (was a trivial white-screen 500). An array coerces to '' and is ignored.
\$search = trim(is_array(\$_GET['search'] ?? '') ? '' : (string) (\$_GET['search'] ?? ''));
\$hideUntilSearch = $hideUntilSearch && \$search === '';
\$sort = is_array(\$_GET['sort'] ?? '') ? '$pk' : (string) (\$_GET['sort'] ?? '$pk');
\$dir = strtoupper(is_array(\$_GET['dir'] ?? '') ? '$defaultDir' : (string) (\$_GET['dir'] ?? '$defaultDir')) === 'DESC' ? 'DESC' : 'ASC';
\$validSorts = $fieldNamesStr;
if (!in_array(\$sort, \$validSorts, true)) \$sort = '$pk';

// Build WHERE
\$where = '';
\$params = [];
if (\$search !== '') {
    \$searchable = $searchableStr;
    // A Search component may restrict which fields to match via ?searchin=col1,col2
    // (validated against real columns, so it can never inject).
    \$__si = trim((string) (\$_GET['searchin'] ?? ''));
    if (\$__si !== '') {
        \$__pick = array_values(array_intersect(array_filter(array_map('trim', explode(',', \$__si))), \$validSorts));
        if (\$__pick) \$searchable = \$__pick;
    }
    \$likes = [];
    foreach (\$searchable as \$f) {
        if (in_array(\$f, \$validSorts, true)) {
            \$likes[] = "`\$f` LIKE ?";
            \$params[] = '%' . pp_like(\$search) . '%';
        }
    }
    if (!empty(\$likes)) {
        \$where = 'WHERE ' . implode(' OR ', \$likes);
    }
}

// Column filters (Filter panel: ?f[column]=value). Dropdown-filter columns
// match EXACTLY; free-text columns use LIKE contains.
\$filters = [];
\$__exactFilters = $exactFiltersStr;
\$__csvFilters = $csvFiltersStr;
\$__filterModes = $filterModesStr;
foreach ((array) (\$_GET['f'] ?? []) as \$__fc => \$__fv) {
    if (!in_array(\$__fc, \$validSorts, true)) continue;
    // Array filter values are either a RANGE (assoc min/max or from/to) or a
    // MULTI-SELECT list. Ranges → >= / <= (dates include the whole 'to' day);
    // lists → IN (…).
    if (is_array(\$__fv)) {
        \$__isRange = isset(\$__fv['min']) || isset(\$__fv['max']) || isset(\$__fv['from']) || isset(\$__fv['to']);
        if (\$__isRange) {
            \$__lo = trim((string) (\$__fv['min'] ?? \$__fv['from'] ?? ''));
            \$__hi = trim((string) (\$__fv['max'] ?? \$__fv['to'] ?? ''));
            \$__isDate = isset(\$__fv['from']) || isset(\$__fv['to']);
            if (\$__lo === '' && \$__hi === '') continue;
            \$filters[\$__fc] = ['min' => \$__lo, 'max' => \$__hi];
            if (\$__lo !== '') {
                \$__cond = "`\$__fc` >= ?"; \$params[] = \$__lo;
                \$where = \$where === '' ? 'WHERE ' . \$__cond : \$where . ' AND ' . \$__cond;
            }
            if (\$__hi !== '') {
                \$__cond = \$__isDate ? "`\$__fc` < DATE_ADD(?, INTERVAL 1 DAY)" : "`\$__fc` <= ?";
                \$params[] = \$__hi;
                \$where = \$where === '' ? 'WHERE ' . \$__cond : \$where . ' AND ' . \$__cond;
            }
            continue;
        }
        \$__vals = array_values(array_filter(array_map(fn(\$x) => trim((string) \$x), \$__fv), fn(\$x) => \$x !== ''));
        if (!\$__vals) continue;
        \$filters[\$__fc] = \$__vals;
        \$__cond = "`\$__fc` IN (" . implode(',', array_fill(0, count(\$__vals), '?')) . ")";
        foreach (\$__vals as \$__vv) \$params[] = \$__vv;
        \$where = \$where === '' ? 'WHERE ' . \$__cond : \$where . ' AND ' . \$__cond;
        continue;
    }
    \$__fv = trim((string) \$__fv);
    if (\$__fv === '') continue;
    \$filters[\$__fc] = \$__fv;
    if (in_array(\$__fc, \$__csvFilters, true)) {
        \$__cond = "FIND_IN_SET(?, `\$__fc`)";
        \$params[] = \$__fv;
    } elseif (in_array(\$__fc, \$__exactFilters, true)) {
        \$__cond = "`\$__fc` = ?";
        \$params[] = \$__fv;
    } else {
        // Free-text match mode (default 'contains').
        \$__mode = \$__filterModes[\$__fc] ?? 'contains';
        if (\$__mode === 'exact') {
            \$__cond = "`\$__fc` = ?";
            \$params[] = \$__fv;
        } else {
            \$__cond = "`\$__fc` LIKE ?";
            \$__lv = pp_like(\$__fv);
            \$params[] = \$__mode === 'starts' ? (\$__lv . '%') : (\$__mode === 'ends' ? ('%' . \$__lv) : ('%' . \$__lv . '%'));
        }
    }
    \$where = \$where === '' ? 'WHERE ' . \$__cond : \$where . ' AND ' . \$__cond;
}

$whereTabsRuntime
// Custom WHERE / ORDER BY from the Events designer
\$__extraWhere = \PpPhp\Core\Events::fire('tables.$tableName.sql.sqlWhere', ['user' => \$_SESSION['user'] ?? [], 'params' => \$_GET]);
if (is_string(\$__extraWhere) && trim(\$__extraWhere) !== '') {
    \$where = \$where === '' ? 'WHERE (' . \$__extraWhere . ')' : \$where . ' AND (' . \$__extraWhere . ')';
}
\$__orderBy = \PpPhp\Core\Events::fire('tables.$tableName.sql.sqlOrderBy', ['user' => \$_SESSION['user'] ?? [], 'params' => \$_GET]);
\$__orderClause = (is_string(\$__orderBy) && trim(\$__orderBy) !== '') ? \$__orderBy : "`\$sort` \$dir";

// Row-Level Security: restrict rows to what this user's groups may see.
\$where = \PpPhp\Core\Auth\RowSecurity::mergeWhere(\$where, \$table);

// "Select all matching": return the PK ids of the WHOLE filtered set (same
// WHERE as the list → RLS-scoped, so a user only gets ids they may act on) so
// bulk delete/update can target every matching row, not just the current page.
// Capped to keep the JSON + the follow-up bulk POST bounded; the client warns.
if (isset(\$_GET['__allids'])) {
    header('Content-Type: application/json');
    \$__pk  = pp_pk(\$table);
    \$__cap = 20000;
    \$__ids = array_map(static fn(\$r) => \$r[\$__pk], \PpPhp\Core\DB::FetchAll("SELECT `\$__pk` FROM `\$table` \$where ORDER BY `\$__pk`", \$params) ?: []);
    echo json_encode(['ids' => array_slice(\$__ids, 0, \$__cap), 'total' => count(\$__ids), 'capped' => count(\$__ids) > \$__cap]);
    exit;
}

if (\$hideUntilSearch) {
    \$records = [];
    \$total = 0;
} else {
    try {
        \$total = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `\$table` \$where", \$params);
        // Clamp the page to the last page so a too-high ?page= fetches the real
        // last slice instead of an empty one with a "481 – 470 of 470" info line.
        \$__lastPage = max(1, (int) ceil(\$total / max(1, \$perPage)));
        if (\$page > \$__lastPage) \$page = \$__lastPage;
        \$offset = (\$page - 1) * \$perPage;
        \$sql = "SELECT * FROM `\$table` \$where ORDER BY \$__orderClause LIMIT \$perPage OFFSET \$offset";
        \$records = \PpPhp\Core\DB::FetchAll(\$sql, \$params);
        if (\$records === false) \$records = [];
        // Field Encryption: decrypt sensitive columns for display.
        \$records = \PpPhp\Core\Auth\FieldCrypto::decryptRows(\$table, \$records);
$pkIdNorm
    } catch (\Throwable \$e) {
        \$error = 'Database error: ' . \$e->getMessage();
        \$records = [];
        \$total = 0;
    }
}

\PpPhp\Core\Events::fire('tables.$tableName.page.afterListLoad', ['records' => &\$records, 'total' => \$total]);
if (\PpPhp\Core\Events::has('tables.$tableName.page.processValues')) {
    foreach (\$records as &\$__row) {
        \PpPhp\Core\Events::fire('tables.$tableName.page.processValues', ['record' => &\$__row, 'action' => 'list']);
    }
    unset(\$__row);
}

\$__from = \$total > 0 ? ((\$page - 1) * \$perPage) + 1 : 0;
\$__to = min(\$total, \$page * \$perPage);
\$recordInfoTpl = $recordInfoTplExpr;
// Resilience: if a translation lost the %total% placeholder (e.g. a machine
// translation mangled the tokens), fall back to the working default so the
// count always renders and never blows up the toolbar width.
if (strpos(\$recordInfoTpl, '%total%') === false) \$recordInfoTpl = 'Displaying %from% – %to% of %total%';
\$recordInfo = str_replace(['%from%', '%to%', '%total%'], [(string) \$__from, (string) \$__to, (string) \$total], \$recordInfoTpl);
$aggPhp

\$fields = [];
PHP;
        // (already seeded above with the View controls; keep appending Edit controls)
        $formEditControls = $this->editControlsFor($tableName);
        foreach ($fields as $f) {
            $list = var_export($f['list'] ?? true, true);
            $sortable = var_export((bool) ($f['sortable'] ?? true), true);
            $headerFilter = var_export((bool) ($f['headerFilter'] ?? false), true);
            $label = addslashes($f['label'] ?? $f['name']);
            $type = addslashes($f['type'] ?? 'text');
            $name = addslashes($f['name']);
            $thStyle = addslashes((string) ($f['thStyle'] ?? ''));
            $tdStyle = addslashes((string) ($f['tdStyle'] ?? ''));
            $thClass = addslashes((string) ($f['thClass'] ?? ''));
            $tdClass = addslashes((string) ($f['tdClass'] ?? ''));
            // Inline-add picker kind: honour a date/time field or an explicit
            // DatePicker/TimePicker edit control so inline add gets a calendar.
            $eCtl = (string) ($f['editControl'] ?? '');
            $eSet = (array) ($f['editSettings'] ?? []);
            // The Add/Edit form is the authoritative place a field's edit control
            // is configured — prefer it over the grid column (which usually has
            // none, or a stale copy) so inline edit / spreadsheet use the real,
            // current control + settings (e.g. the Nepali picker's paired field).
            if (isset($formEditControls[$f['name']])) {
                $eCtl = $formEditControls[$f['name']]['editControl'];
                $eSet = $formEditControls[$f['name']]['editSettings'];
            }
            $lt = strtolower((string) ($f['type'] ?? ''));
            $pickerKind = '';
            if ($eCtl === 'DatePicker' || in_array($lt, ['date', 'datetime', 'datetime-local'], true)) {
                $wt = in_array(strtolower((string) ($eSet['withTime'] ?? '')), ['1', 'yes', 'true', 'on'], true);
                $pickerKind = ($wt || in_array($lt, ['datetime', 'datetime-local'], true)) ? 'datetime' : 'date';
            } elseif ($eCtl === 'TimePicker' || $lt === 'time') {
                $pickerKind = 'time';
            }
            // Lookup Wizard fields (from the Add/Edit form design): the list
            // shows the lookup table's display value instead of the raw key.
            $lookupExport = isset($lookupFields[$f['name']])
                ? ", 'lookup' => " . var_export($lookupFields[$f['name']], true)
                : '';
            // Static option map (Dropdown / Switch / manual Lookup …) → the grid
            // shows the label; pp_build_lookup_maps merges it into $lookupMaps.
            $optionMapExport = isset($optionMaps[$f['name']])
                ? ", 'optionMap' => " . var_export($optionMaps[$f['name']], true)
                : '';
            // Carry the plugin edit control so inline-edit / spreadsheet / inline-add
            // can mount the REAL control (not a plain input) — see PP_INLINE_EDITORS.
            $editControlExport = ($eCtl !== '' && $eCtl !== 'TextBox')
                ? ", 'editControl' => '" . addslashes($eCtl) . "', 'editSettings' => " . var_export($eSet, true)
                : '';
            if ($eCtl !== '') $listUsedControls[$eCtl] = true;
            $content .= "\$fields[] = ['name' => '$name', 'label' => '$label', 'type' => '$type', 'list' => $list, 'sortable' => $sortable, 'headerFilter' => $headerFilter, 'thStyle' => '$thStyle', 'tdStyle' => '$tdStyle', 'thClass' => '$thClass', 'tdClass' => '$tdClass', 'pickerKind' => '$pickerKind'$lookupExport$optionMapExport$editControlExport];\n";
        }
        // Plugin libraries needed by inline editors on this list (flatpickr is already loaded).
        [$listPluginCss, $listPluginJs] = $this->pluginHeadAssets(array_keys($listUsedControls));

        // Lookup option sets (key => display) for the filter panel, the
        // update-selected panel, and inline edit (window.PP_LOOKUPS).
        $content .= "\$__ppLookups = [];\n";
        foreach ($lookupFields as $lname => $lk) {
            $ln = addslashes($lname);
            $whereSql = ($lk['where'] ?? '') !== '' ? ' WHERE (' . str_replace(['\\', '"', '$'], ['\\\\', '\\"', '\\$'], $lk['where']) . ')' : '';
            $content .= "try { \$__ppLookups['$ln'] = []; foreach (\\PpPhp\\Core\\DB::FetchAll(\"SELECT `{$lk['key']}` AS k, `{$lk['display']}` AS d FROM `{$lk['table']}`$whereSql ORDER BY `{$lk['display']}` ASC LIMIT 1000\") ?: [] as \$__lr) { \$__ppLookups['$ln'][(string) \$__lr['k']] = (string) \$__lr['d']; } } catch (\\Throwable \$e) {}\n";
        }
        // Properties-Panel head: SEO & sharing meta, Advanced → Head code, and
        // Layout → Dark mode (is-dark on <html>) so those panels are LIVE on the
        // generated list page — not just custom pages.
        $listMetaHead = $this->pageSettingsHead($design, htmlspecialchars($tableLabel . ' - List', ENT_QUOTES));
        $listHead    = $listMetaHead['head'];
        $listHtmlCls = $listMetaHead['htmlCls'];
        $content .= <<<PHP

?><!DOCTYPE html>
<html lang="en"$listHtmlCls>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$tableLabel - List</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css">$listPluginCss<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css">$listHead</head>
<body data-pp-table="$tableName" data-pp-action="list">
<?php \$__caption = pp_table_caption('$tableName', '$tableLabel'); ?>
<?php pp_shell_start(pp_page_title('$tableName', 'list', $listTitleFallback), '$tableName', $crumbArg); ?>
<?= flashMessage() ?>
<?php if (!empty(\$error)): ?><div class="alert alert-danger"><?= htmlspecialchars(\$error) ?></div><?php endif; ?>
<div class="pp-zone12 pp-list-zone gap-normal">
$extraHtml
$whereTabsBar
$toolbarOpen
<div class="pp-tb-zone pp-tb-left">
$tbLeft
</div>
<div class="pp-tb-zone pp-tb-center">
$tbCenter
</div>
<div class="pp-tb-zone pp-tb-right">
$tbRight
</div>
$toolbarClose

<?php if (\$filters): ?>
<div$chipsAttr><i class="bi bi-funnel"></i><span class="pp-chips-label">Filters</span>
<?php foreach (\$filters as \$__fc => \$__fv): \$__q = \$_GET; unset(\$__q['f'][\$__fc]);
    if (is_array(\$__fv)) {
        if (isset(\$__fv['min']) || isset(\$__fv['max'])) {
            \$__lo = (string) (\$__fv['min'] ?? ''); \$__hi = (string) (\$__fv['max'] ?? '');
            \$__disp = (\$__lo !== '' ? \$__lo : '…') . ' – ' . (\$__hi !== '' ? \$__hi : '…');
        } else {
            \$__disp = implode(', ', array_map('strval', \$__fv));
        }
    } else { \$__disp = (string) \$__fv; }
?>
<a class="pp-chip" href="?<?= htmlspecialchars(http_build_query(\$__q)) ?>"><?= htmlspecialchars(ucwords(str_replace('_', ' ', \$__fc))) ?>: <?= htmlspecialchars(\$__disp) ?> <i class="bi bi-x"></i></a>
<?php endforeach; \$__qa = \$_GET; unset(\$__qa['f']); ?>
<a class="pp-chip-clear" href="?<?= htmlspecialchars(http_build_query(\$__qa)) ?>">Clear all</a>
</div>
<?php endif; ?>

<?php if ($selection): ?>
$selbarOpen
<i class="bi bi-check2-circle" style="color:var(--pp-primary)"></i>
<span class="pp-sel-count" id="ppSelCount" role="status" aria-live="polite" aria-atomic="true"></span>
<?php if ($selHasDelete && $canDelete): ?><button class="ppd-bulk-delete" data-pp-btn="deleteSelected" data-table="$tableName"><i class="bi bi-trash3"></i> <?= pp_t('ui.delete', 'Delete') ?></button><?php endif; ?>
<?php if ($selHasUpdate): ?><button type="button" class="ppd-grid-toolbar-btn" data-pp-btn="editSelected" onclick="var p=document.getElementById('ppUpdPanel');p.style.display=p.style.display==='none'?'block':'none'"><i class="bi bi-pencil-square"></i> <?= htmlspecialchars(pp_t('ui.update', 'Update')) ?></button><?php endif; ?>
<?php if ($selHasExport): ?><button type="button" class="ppd-grid-toolbar-btn" data-pp-btn="exportSelected" id="ppSelExport"><i class="bi bi-download"></i> <?= pp_t('ui.export', 'Export') ?></button><?php endif; ?>
<button type="button" class="ppd-grid-toolbar-btn" onclick="window.ppCopySelection&&window.ppCopySelection()" data-pp-tip="<?= htmlspecialchars(pp_t('ui.copy_clipboard', 'Copy to clipboard'), ENT_QUOTES) ?>" aria-label="<?= htmlspecialchars(pp_t('ui.copy_clipboard', 'Copy to clipboard'), ENT_QUOTES) ?>"><i class="bi bi-clipboard"></i> <?= pp_t('ui.copy', 'Copy') ?></button>
<?php if ($selShowClear): ?><button type="button" class="ppd-grid-toolbar-btn" onclick="document.querySelectorAll('.ppd-select-row:checked,.ppd-select-all:checked').forEach(function(c){c.checked=false});window.ppAllMatching&&(window.ppAllMatching.active=false,window.ppAllMatching.ids=[]);document.getElementById('bulkActions').style.display='none'"><?= pp_t('ui.clear', 'Clear') ?></button><?php endif; ?>
</div>
<?php if ($selHasUpdate): ?>
<div class="ppg-form-section pp-upd-panel" id="ppUpdPanel" style="display:none">
<h3 class="ppg-form-heading" style="margin-bottom:.4rem">Update selected records</h3>
<p style="color:var(--pp-muted);font-size:.8rem;margin:0 0 .8rem">Leave a field blank to keep its current value.</p>
<div class="ppg-form-grid pp-gap-compact" style="--sp:4">
$updFields
</div>
<div style="margin-top:.9rem;display:flex;gap:.5rem">
<button type="button" class="ppd-auth-action" style="width:auto" id="ppUpdApply">Apply to selected</button>
<button type="button" class="ppd-grid-toolbar-btn" onclick="document.getElementById('ppUpdPanel').style.display='none'"><?= htmlspecialchars(pp_t('ui.cancel', 'Cancel')) ?></button>
</div>
</div>
<?php endif; ?>
<?php endif; ?>

<?php if (\$hideUntilSearch): ?>
<p style="color:var(--pp-muted,#64748b)"><i class="bi bi-search"></i> <?= pp_t('ui.search_to_view', 'Enter a search to view records.') ?></p>
<?php elseif (!empty(\$records)): ?>
<?php if ($pagTop): ?><?= renderPagination(\$total, \$page, \$perPage, APP_BASE . '/generated/$tableName/list.php', array_merge(array_diff_key(\$_GET, ['page' => 1]), ['search' => \$search, 'sort' => \$sort, 'dir' => \$dir, 'perPage' => \$perPage]), $pagOpts) ?><?php endif; ?>
<?= renderTable(\$records, \$fields, '$tableName', [
    'sort' => \$sort, 'dir' => \$dir, 'page' => \$page, 'search' => \$search,
    'caption' => $gridCaptionExport, 'captionSr' => \$__caption,
    'rowNumbers' => $gridRowNumbers, 'rowStart' => (\$page - 1) * \$perPage,
    'headerFilters' => $gridHeaderFilters, 'conditionalRules' => $gridCondRulesExport,
    'selectionMode' => $selectionModeExport,
    'responsive' => $gridResponsiveExport,
    'gridClass' => '$gridClass',
    'wrapClass' => '$gridWrapClass',
    'wrapStyle' => '$gridWrapStyle',
    'selection' => $selection,
    'actions' => ['view' => $canView, 'edit' => $canEdit, 'delete' => $canDelete, 'show' => $showActions, 'items' => $actionItemsExport, 'editPopup' => $editPopupFlag, 'viewPopup' => $viewPopupFlag],
    'viewAs' => $viewAsExport,
    'aggregates' => \$aggregates,
    'inlineMeta' => $inlineMeta,
    'inlineAdd' => $inlineAddFlag,
    'editableFields' => $editableFieldsExport,
    'details' => $detailsExport,
    'detailsHeader' => $detailsHeaderExport,
    'detailsPosition' => $detailsPositionExport,
    'detailsStyle' => $detailsStyleExport,
    'detailsAlign' => $detailsAlignExport,
]) ?>
<?php else: ?>
<?php \$__hasFilters = (\$search !== '' || !empty(\$_GET['f']) || (isset(\$_GET['wtab']) && \$_GET['wtab'] !== '')); ?>
<div class="pp-empty-state">
<i class="bi <?= \$__hasFilters ? 'bi-funnel' : 'bi-inbox' ?> pp-empty-ic" aria-hidden="true"></i>
<p class="pp-empty-msg"><?= \$__hasFilters ? htmlspecialchars(pp_t('ui.no_match', 'No records match your filters.')) : htmlspecialchars(pp_t('ui.no_records', '$emptyTextDefault')) ?></p>
<?php if (\$__hasFilters): ?><a class="btn btn-sm btn-outline-secondary" href="<?= APP_BASE ?>/generated/$tableName/list.php"><i class="bi bi-arrow-counterclockwise"></i> <?= htmlspecialchars(pp_t('ui.clear_filters', 'Clear filters')) ?></a><?php else: ?>$emptyAddCta<?php endif; ?>
</div>
<?php endif; ?>
$cardsMount
<div class="pp-list-footer">
<div class="pp-lf-left">
$buttonsHtml
</div>
<div class="pp-lf-right">
<?php if ($pagBottom && !\$hideUntilSearch): ?><?= renderPagination(\$total, \$page, \$perPage, APP_BASE . '/generated/$tableName/list.php', array_merge(array_diff_key(\$_GET, ['page' => 1]), ['search' => \$search, 'sort' => \$sort, 'dir' => \$dir, 'perPage' => \$perPage]), $pagOpts) ?><?php endif; ?>
</div>
</div>
</div><!-- /.pp-list-zone -->
$loadingBoxHtml
$addModalHtml
$pageStyleBlock
$pageScriptBlock
<script>
(function () {
    // Grid-state store — same localStorage key ('ppList:<table>') as the List
    // Settings block, defined locally so column show/hide works even though the
    // two features live in separate <script> scopes (fixes "loadS is not defined").
    var __ppGt0 = document.querySelector('.ppd-grid-table');
    var LSKEY = 'ppList:' + ((__ppGt0 && __ppGt0.getAttribute('data-pp-table')) || '');
    function loadS() { try { return JSON.parse(localStorage.getItem(LSKEY) || '{}'); } catch (e) { return {}; } }
    function saveS(patch) { var s = loadS(); for (var k in patch) s[k] = patch[k]; try { localStorage.setItem(LSKEY, JSON.stringify(s)); } catch (e) {} if (window.ppSyncViewUrl) window.ppSyncViewUrl(); }
    // selection count + columns toggle (designed widgets)
    var selTpl = '$selCountTpl';
    var PP_TOTAL = <?= (int) \$total ?>;
    // "Select all matching" state: when active, bulk actions target every row in
    // the current filter (fetched RLS-scoped from list.php?__allids), not just the
    // ticked page rows. All three actions consult window.ppAllMatching.
    window.ppAllMatching = { active: false, ids: [] };
    function ppCheckedCount() { return document.querySelectorAll('.ppd-select-row:checked').length; }
    function ppPageRows() { return document.querySelectorAll('.ppd-select-row').length; }
    function resetAllMatching() { window.ppAllMatching.active = false; window.ppAllMatching.ids = []; }
    function selBanner() {
        var bar = document.getElementById('bulkActions'); if (!bar) return null;
        var b = document.getElementById('ppSelAllBanner');
        if (!b) { b = document.createElement('span'); b.id = 'ppSelAllBanner'; b.className = 'pp-selall-banner'; bar.appendChild(b); }
        return b;
    }
    function updateBanner() {
        var b = selBanner(); if (!b) return;
        var pr = ppPageRows(), ck = ppCheckedCount(), clr = (window.PP_SF_I18N || {}).clear || 'Clear';
        if (window.ppAllMatching.active) {
            b.innerHTML = '<span class="pp-selall-msg"><i class="bi bi-check-all"></i> all ' + window.ppAllMatching.ids.length + ' matching selected</span> <a href="#" class="pp-selall-link" data-selall-clear>' + clr + '</a>';
        } else if (pr > 0 && ck === pr && PP_TOTAL > pr) {
            b.innerHTML = '<a href="#" class="pp-selall-link" data-selall-all>Select all ' + PP_TOTAL + ' matching this filter</a>';
        } else { b.innerHTML = ''; }
    }
    function refreshSelCount() {
        // deferred: the select-all handler (app.js) updates row checkboxes
        // after this change event, so count on the next tick
        setTimeout(function () {
            var el = document.getElementById('ppSelCount');
            var n = window.ppAllMatching.active ? window.ppAllMatching.ids.length : ppCheckedCount();
            if (el) el.textContent = selTpl.replace('%n%', String(n));
            updateBanner();
        }, 0);
    }
    document.addEventListener('change', function (e) {
        if (!e.target.classList) return;
        if (e.target.classList.contains('ppd-select-row') || e.target.classList.contains('ppd-select-all')) {
            if (window.ppAllMatching.active) resetAllMatching();   // any manual (de)selection exits all-matching
            refreshSelCount();
        }
    });
    // Banner actions: fetch all matching ids (RLS-scoped) or clear the selection.
    document.addEventListener('click', function (e) {
        var all = e.target.closest ? e.target.closest('[data-selall-all]') : null;
        var clr = e.target.closest ? e.target.closest('[data-selall-clear]') : null;
        if (all) {
            e.preventDefault(); all.textContent = 'Loading…';
            var u = new URL('list.php', location.href); u.search = location.search;
            u.searchParams.set('__allids', '1'); u.searchParams.delete('page');
            fetch(u.pathname + u.search, { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (j) {
                window.ppAllMatching.active = true;
                window.ppAllMatching.ids = (j && j.ids) || [];
                if (j && j.capped) alert('Only the first ' + window.ppAllMatching.ids.length + ' of ' + j.total + ' matching records can be acted on at once.');
                refreshSelCount();
            }).catch(function () { updateBanner(); });
        } else if (clr) {
            e.preventDefault(); resetAllMatching();
            document.querySelectorAll('.ppd-select-row:checked,.ppd-select-all:checked').forEach(function (c) { c.checked = false; });
            var bar = document.getElementById('bulkActions'); if (bar) bar.style.display = 'none';
            refreshSelCount();
        }
    });
    function selIds() {
        if (window.ppAllMatching.active && window.ppAllMatching.ids.length) return window.ppAllMatching.ids.slice();
        return Array.prototype.map.call(document.querySelectorAll('.ppd-select-row:checked'), function (c) { return c.value; });
    }
    // "Export selected" opens the Export screen scoped to the ticked rows (or, in
    // all-matching mode, the whole filtered set — export.php's default scope).
    var selExp = document.getElementById('ppSelExport');
    if (selExp) selExp.addEventListener('click', function () {
        var u = new URL('export.php', location.href);
        u.search = location.search;
        u.searchParams.delete('format'); u.searchParams.delete('__allids');
        if (window.ppAllMatching.active) { u.searchParams.delete('ids'); }        // whole filtered set
        else { var ids = selIds(); if (!ids.length) return; u.searchParams.set('ids', ids.join(',')); }
        location.href = u.pathname + u.search;
    });

    // Every Data-menu link inherits the current row selection at click time, so
    // the Export screen can default to "Selected records" and a quick download
    // covers exactly the ticked rows.
    document.addEventListener('click', function (e) {
        var a = e.target.closest ? e.target.closest('a[data-pp-data-link]') : null;
        if (!a) return;
        var ids = selIds();
        var u = new URL(a.getAttribute('href'), location.href);
        if (ids.length) u.searchParams.set('ids', ids.join(','));
        else u.searchParams.delete('ids');
        a.setAttribute('href', u.pathname + u.search);
    });
    var updApply = document.getElementById('ppUpdApply');
    if (updApply) updApply.addEventListener('click', function () {
        var ids = selIds(); if (!ids.length) { alert('Select at least one record.'); return; }
        var data = {};
        document.querySelectorAll('[data-upd-field]').forEach(function (el) {
            if (el.value !== '') data[el.getAttribute('data-upd-field')] = el.value;
        });
        if (!Object.keys(data).length) { alert('Enter a value in at least one field.'); return; }
        updApply.disabled = true;
        fetch((window.PP_BASE || '') + '/api/update-selected.php', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'same-origin',
            body: JSON.stringify({ table: document.body.getAttribute('data-pp-table'), ids: ids, data: data })
        }).then(function (r) { return r.json(); }).then(function (j) {
            if (j && j.success) location.reload();
            else { alert((j && j.error) || 'Update failed'); updApply.disabled = false; }
        }).catch(function () { updApply.disabled = false; });
    });
    var menu = document.getElementById('ppColsMenu');
    var table = document.querySelector('.ppd-grid-table');
    if (menu && table) {
        // Column visibility is keyed by column NAME (data-col) so it survives
        // reordering, and persisted (loadS().hidden) → shareable via ?pv=.
        var hiddenCols = (loadS().hidden || []);
        var colCells = function (col) { return table.querySelectorAll('[data-col="' + (window.CSS && CSS.escape ? CSS.escape(col) : col) + '"]'); };
        var applyColHidden = function (col, visible) { colCells(col).forEach(function (cell) { cell.style.display = visible ? '' : 'none'; }); };
        table.querySelectorAll('thead th[data-col]').forEach(function (th) {
            var col = th.getAttribute('data-col'); if (!col) return;
            var label = (th.textContent || '').trim() || col;
            var visible = hiddenCols.indexOf(col) < 0;
            if (!visible) applyColHidden(col, false);
            var row = document.createElement('label');
            var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = visible; cb.setAttribute('data-col', col);
            row.appendChild(cb); row.appendChild(document.createTextNode(' ' + label));
            cb.addEventListener('change', function () {
                applyColHidden(col, this.checked);
                var h = (loadS().hidden || []).filter(function (c) { return c !== col; });
                if (!this.checked) h.push(col);
                saveS({ hidden: h });
            });
            menu.appendChild(row);
        });
        // Footer: copy a shareable link that reproduces this arranged view
        // (the ?pv= token carries column order/width/pins/density/hidden/mode).
        var copyLbl = '<i class="bi bi-link-45deg"></i> <?= htmlspecialchars(pp_t('ui.copy_view_link', 'Copy link to this view'), ENT_QUOTES) ?>';
        var foot = document.createElement('div'); foot.className = 'pp-cols-foot';
        var lnk = document.createElement('button'); lnk.type = 'button'; lnk.className = 'pp-cols-copylink'; lnk.innerHTML = copyLbl;
        lnk.addEventListener('click', function () {
            if (window.ppSyncViewUrl) window.ppSyncViewUrl();
            var url = location.href, ok = function () { lnk.innerHTML = '<i class="bi bi-check2"></i> <?= htmlspecialchars(pp_t('ui.copied', 'Copied'), ENT_QUOTES) ?>'; setTimeout(function () { lnk.innerHTML = copyLbl; }, 1500); };
            if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(ok).catch(function () {});
            else { var ta = document.createElement('textarea'); ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) {} ta.remove(); }
        });
        foot.appendChild(lnk); menu.appendChild(foot);
    }
    // Close toolbar dropdowns (Export / Filter / Columns — all <details>) on an
    // outside click or Escape. Native <details> only closes via its own summary.
    document.addEventListener('click', function (e) {
        document.querySelectorAll('details.pp-cols-toggle[open]').forEach(function (d) {
            if (!d.contains(e.target)) d.removeAttribute('open');
        });
    });
    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') document.querySelectorAll('details.pp-cols-toggle[open]').forEach(function (d) { d.removeAttribute('open'); });
    });
    // ── Toolbar auto-fit (desktop = ALWAYS one row) ──
    //    Above 992px the toolbar must stay a single tidy row at any width. Strategy,
    //    least-destructive first: (1) move the lowest-priority actions (data-pp-ov)
    //    into the "⋯ More" menu — keeps primary labels + the record count visible;
    //    (2) if still tight, drop the record count; (3) last resort, collapse the
    //    remaining secondary text labels to icons. ≤992px is a CSS two-row layout,
    //    so the JS restores everything and hides the More menu there.
    (function () {
        var ppTbs = [].slice.call(document.querySelectorAll('.pp-toolbar'));
        if (!ppTbs.length) return;
        ppTbs.forEach(function (tb) {
            var more = tb.querySelector('.pp-tb-more');
            var moreMenu = more ? more.querySelector('.pp-more-menu') : null;
            var movable = [].slice.call(tb.querySelectorAll('[data-pp-ov]'))
                .sort(function (a, b) { return (+a.getAttribute('data-pp-ov')) - (+b.getAttribute('data-pp-ov')); });
            movable.forEach(function (el) { el._home = el.parentNode; el._next = el.nextElementSibling; });
            function rowH() { var b = tb.querySelector('.ppd-grid-toolbar-btn'); return b ? b.offsetHeight : 34; }
            function wrapped() { return tb.offsetHeight > rowH() * 1.55; }
            function restore() {
                // reinsert every movable at its original DOM slot, strip injected label
                movable.forEach(function (el) {
                    if (el.parentNode !== el._home) {
                        var l = el.querySelector('.pp-more-lbl'); if (l) l.remove();
                        el._home.insertBefore(el, (el._next && el._next.parentNode === el._home) ? el._next : null);
                    }
                });
                tb.classList.remove('pp-tb-compact', 'pp-tb-hideinfo');
                if (more) more.hidden = true;
            }
            function moveIn(el) {
                if (!el.querySelector('.pp-tb-txt') && el.getAttribute('data-pp-tip')) {
                    var s = document.createElement('span'); s.className = 'pp-more-lbl'; s.textContent = el.getAttribute('data-pp-tip'); el.appendChild(s);
                }
                moreMenu.insertBefore(el, moreMenu.firstChild);
            }
            tb._fit = function () {
                restore();
                if (window.innerWidth <= 992) return;      // CSS handles the two-row layout
                var i = movable.length - 1;
                while (moreMenu && wrapped() && i >= 0) { more.hidden = false; moveIn(movable[i]); i--; }
                if (moreMenu && !moreMenu.children.length && more) more.hidden = true;
                if (wrapped()) tb.classList.add('pp-tb-hideinfo');
                if (wrapped()) tb.classList.add('pp-tb-compact');
            };
        });
        function fit() { ppTbs.forEach(function (tb) { tb._fit(); }); }
        // Menus near the right edge open leftwards so they never run off-screen.
        function anchor() {
            document.querySelectorAll('.pp-cols-toggle, .pp-tb-more').forEach(function (d) {
                var r = d.getBoundingClientRect();
                d.classList.toggle('pp-anchor-r', r.left > window.innerWidth * 0.55);
            });
        }
        var t;
        window.addEventListener('resize', function () { clearTimeout(t); t = setTimeout(function () { fit(); anchor(); }, 90); });
        if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { fit(); anchor(); });
        window.addEventListener('load', function () { fit(); anchor(); });
        fit(); anchor();
    })();
    // Unified search: the in-field submit (→) runs the search; once results match
    // what's typed, the field enters "clear mode" and shows a × that resets the
    // filter (the trailing submit hides so there's one action at a time).
    var ppSearchForm = document.querySelector('.pp-search-form');
    if (ppSearchForm) {
        var ppSInput = ppSearchForm.querySelector('input[type="search"]');
        var ppSBox = ppSearchForm.querySelector('.pp-search-box');
        var ppSActive = (ppSInput.getAttribute('data-active') || '');
        var ppSync = function () {
            var val = ppSInput.value.trim();
            var searched = ppSActive !== '' && val === ppSActive;   // showing results for the typed query
            ppSearchForm.classList.toggle('is-searched', ppSActive !== '');
            if (ppSBox) ppSBox.classList.toggle('is-clear-mode', searched);
        };
        ppSInput.addEventListener('input', ppSync);
        ppSync();
    }
})();
</script>
<?php pp_shell_end(); ?>
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script>window.PP_LOOKUPS = <?= json_encode(\$__ppLookups ?? [], JSON_HEX_TAG | JSON_HEX_AMP) ?>;window.PP_LOOKUPS_MULTI = $csvFiltersStr;</script>
<script>window.PP_CSRF = <?= json_encode(\PpPhp\Core\Security::CSRF()->Token(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
<script>
/* Shareable view-state: an arranged view (column order/width, pins, density,
   hidden columns, view mode) is normally per-browser (localStorage). This runs
   FIRST — before any grid script — and, when the URL carries a ?pv= token,
   restores that snapshot into localStorage so every enhancer picks up the shared
   view. window.ppSyncViewUrl() re-encodes the snapshot into ?pv= (replaceState)
   whenever a preference changes, keeping the address bar shareable. */
(function () {
    var T = (document.body && document.body.getAttribute('data-pp-table')) || location.pathname;
    var LK = 'ppList:' + T, VK = 'ppView:' + T;
    function dec(s) { try { s = s.replace(/-/g, '+').replace(/_/g, '/'); return decodeURIComponent(escape(atob(s))); } catch (e) { return ''; } }
    try {
        var pv = new URLSearchParams(location.search).get('pv');
        if (pv) {
            var o = JSON.parse(dec(pv) || '{}');
            if (typeof o.l === 'string') { o.l ? localStorage.setItem(LK, o.l) : localStorage.removeItem(LK); }
            if (typeof o.v === 'string') { o.v ? localStorage.setItem(VK, o.v) : localStorage.removeItem(VK); }
        }
    } catch (e) {}
    window.ppSyncViewUrl = function () {
        try {
            var snap = { l: localStorage.getItem(LK) || '', v: localStorage.getItem(VK) || '' }, enc = '';
            if (snap.l || snap.v) { enc = btoa(unescape(encodeURIComponent(JSON.stringify(snap)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+\$/, ''); }
            var u = new URL(location.href);
            if (enc) u.searchParams.set('pv', enc); else u.searchParams.delete('pv');
            history.replaceState(null, '', u.pathname + u.search + u.hash);
        } catch (e) {}
    };
})();
</script>
<script>window.PP_SF_I18N = <?= json_encode(['search' => pp_t('ui.search', 'Search…'), 'select_all' => pp_t('ui.select_all', 'Select all'), 'loading' => pp_t('ui.loading', 'Loading…'), 'clear' => pp_t('ui.clear', 'Clear'), 'cancel' => pp_t('ui.cancel', 'Cancel'), 'apply' => pp_t('ui.apply', 'Apply'), 'ok' => pp_t('ui.ok', 'OK')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?>;</script>
<script>
/* ── List Settings runtime: reorder columns · resize columns · click actions ── */
(function () {
    var TABLE = '$tableName';
    var REORDER = $lsReorderJs, RESIZE = $lsResizeJs, CLICKS = $lsClicksJson;
    var PINCOLS = $lsPinJs, GROUPBY = $lsGroupJson, GROUP_AGG = $lsGroupAggJson, SAVEDVIEWS = $lsViewsJs, KANBAN_FIELD = $lsKanbanJson, DESIGN_PINS = $lsDesignPinsJson;
    var table = document.querySelector('.ppd-grid-table');
    if (!table || !table.tHead || !table.tBodies[0]) return;
    var thead = table.tHead, tbody = table.tBodies[0];

    /* ===== Floating tooltips for in-grid controls =====
       Anchored to <body> in fixed position so no scroll container can clip
       them, and flipped below the control when there is no room above. */
    (function () {
        var tip = null, hideT = null;
        function el() {
            if (!tip) { tip = document.createElement('div'); tip.className = 'pp-tipf'; document.body.appendChild(tip); }
            return tip;
        }
        function show(t) {
            var label = t.getAttribute('data-pp-tip');
            if (!label) return;
            clearTimeout(hideT);
            var n = el();
            n.textContent = label;
            n.style.visibility = 'hidden'; n.classList.add('is-on');
            var r = t.getBoundingClientRect(), b = n.getBoundingClientRect();
            var left = Math.round(r.left + r.width / 2 - b.width / 2);
            left = Math.max(6, Math.min(left, window.innerWidth - b.width - 6));  // keep on screen
            var above = r.top - b.height - 8;
            n.style.left = left + 'px';
            n.style.top = (above >= 6 ? above : r.bottom + 8) + 'px';
            n.style.visibility = '';
        }
        function hide() { if (!tip) return; tip.classList.remove('is-on'); hideT = setTimeout(function () {
            if (tip) tip.style.top = '-9999px'; }, 150); }
        document.addEventListener('mouseover', function (e) {
            var t = e.target.closest('.ppd-grid-table [data-pp-tip]');
            if (t) show(t);
        });
        document.addEventListener('mouseout', function (e) {
            if (e.target.closest('.ppd-grid-table [data-pp-tip]')) hide();
        });
        document.addEventListener('focusin', function (e) {
            var t = e.target.closest('.ppd-grid-table [data-pp-tip]');
            if (t) show(t);
        });
        document.addEventListener('focusout', hide);
        // a tooltip left hanging over a scrolled/closed row reads as a glitch
        document.addEventListener('click', hide);
        window.addEventListener('scroll', hide, true);
    })();

    /* ===== Row actions rail: tap to open (touch has no :hover) ===== */
    (function () {
        function closeAll(except) {
            document.querySelectorAll('td.ppd-grid-actions.pp-act-open').forEach(function (td) {
                if (td === except) return;
                td.classList.remove('pp-act-open');
                var b = td.querySelector('.pp-act-more');
                if (b) b.setAttribute('aria-expanded', 'false');
            });
        }
        document.addEventListener('click', function (e) {
            var more = e.target.closest('.pp-act-more');
            if (more) {
                var td = more.closest('td.ppd-grid-actions');
                var open = td.classList.toggle('pp-act-open');
                more.setAttribute('aria-expanded', open ? 'true' : 'false');
                closeAll(td);
                e.stopPropagation();
                return;
            }
            // a click inside the flyout is an action; let it run, then close
            closeAll(e.target.closest('.pp-act-pop') ? e.target.closest('td.ppd-grid-actions') : null);
        });
        document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); });
    })();

    /* ===== Pinned actions column: flag horizontal scroll for the edge shadow ===== */
    (function () {
        var wrap = table.closest('.ppd-grid-table-wrap');
        if (!wrap) return;
        var mark = function () {
            wrap.classList.toggle('pp-scrolled-x', wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft > 1);
        };
        wrap.addEventListener('scroll', mark, { passive: true });
        window.addEventListener('resize', mark);
        mark();
    })();
    var LSKEY = 'ppList:' + TABLE;
    function loadS() { try { return JSON.parse(localStorage.getItem(LSKEY) || '{}'); } catch (e) { return {}; } }
    function saveS(patch) { var s = loadS(); for (var k in patch) s[k] = patch[k]; try { localStorage.setItem(LSKEY, JSON.stringify(s)); } catch (e) {} if (window.ppSyncViewUrl) window.ppSyncViewUrl(); }
    // Apply persisted density + hidden columns (shared via ?pv= or personal
    // localStorage) on load, and expose helpers that persist + sync them.
    (function () {
        var s0 = loadS(), tbl = document.querySelector('.ppd-grid-table');
        if (tbl && s0.density) { tbl.classList.remove('pp-compact', 'pp-comfortable'); if (s0.density !== 'normal') tbl.classList.add('pp-' + s0.density); }
        window.ppCycleDensity = function () {
            var t = document.querySelector('.ppd-grid-table'); if (!t) return;
            var order = ['pp-compact', '', 'pp-comfortable'], cur = t.classList.contains('pp-compact') ? 0 : t.classList.contains('pp-comfortable') ? 2 : 1;
            t.classList.remove('pp-compact', 'pp-comfortable'); var n = order[(cur + 1) % 3]; if (n) t.classList.add(n);
            saveS({ density: n === 'pp-compact' ? 'compact' : n === 'pp-comfortable' ? 'comfortable' : 'normal' });
        };
    })();
    function dataCols() { return Array.prototype.slice.call(thead.rows[0].cells).filter(function (th) { return th.getAttribute('data-col'); }); }

    /* ===== Reorder columns (drag headers) ===== */
    // Remembered so rows created AFTER load (inline-edit clones, an ajax
    // refresh) can be brought into the same order — see orderRow() below.
    var curOrder = null;
    function orderRow(row, order) {
        var byField = {}, dataCells = [];
        Array.prototype.forEach.call(row.cells, function (c) { var f = c.getAttribute('data-col'); if (f) { byField[f] = c; dataCells.push(c); } });
        if (!dataCells.length) return;
        var anchor = dataCells[dataCells.length - 1].nextSibling;
        order.forEach(function (f) { if (byField[f]) row.insertBefore(byField[f], anchor); });
    }
    function allRows() {
        // thead + tbody + tfoot: the aggregate footer must track the header too,
        // or its totals sit under the wrong columns once anything is dragged.
        var rows = [thead.rows[0]].concat(Array.prototype.slice.call(tbody.rows));
        if (table.tFoot) rows = rows.concat(Array.prototype.slice.call(table.tFoot.rows));
        return rows;
    }
    function applyOrder(order) {
        if (!order || !order.length) return;
        curOrder = order;
        allRows().forEach(function (row) { orderRow(row, order); });
    }
    if (REORDER) {
        table.classList.add('pp-reorderable');
        var savedOrder = loadS().order;
        if (savedOrder) applyOrder(savedOrder);
        // Any row added later inherits the current order automatically.
        if (window.MutationObserver) {
            new MutationObserver(function (muts) {
                if (!curOrder) return;
                muts.forEach(function (m) {
                    Array.prototype.forEach.call(m.addedNodes, function (n) {
                        if (n.nodeType === 1 && n.tagName === 'TR') orderRow(n, curOrder);
                    });
                });
            }).observe(tbody, { childList: true });
        }
        var dragTh = null, dropTh = null, dropAfter = false, colGhost = null;
        dataCols().forEach(function (th) {
            th.setAttribute('draggable', 'true');
            // Links inside a header (the sort link) are draggable by default and
            // would hijack the column drag — pin them down.
            Array.prototype.forEach.call(th.querySelectorAll('a'), function (a) { a.setAttribute('draggable', 'false'); });
            // Visible grip affordance so it's obvious the column can be dragged.
            if (!th.querySelector('.pp-col-grip')) {
                var g = document.createElement('span');
                g.className = 'pp-col-grip'; g.setAttribute('aria-hidden', 'true');
                g.title = 'Drag to reorder column';
                g.innerHTML = '<i class="bi bi-grip-vertical"></i>';
                th.insertBefore(g, th.firstChild);
            }
        });
        function clearOver() { dataCols().forEach(function (t) { t.classList.remove('pp-col-over-left', 'pp-col-over-right'); }); }
        thead.addEventListener('dragstart', function (e) {
            var th = e.target.closest('th[data-col]'); if (!th) return;
            dragTh = th; th.classList.add('pp-col-dragging'); e.dataTransfer.effectAllowed = 'move';
            try { e.dataTransfer.setData('text/plain', th.getAttribute('data-col')); } catch (x) {}
            if (e.dataTransfer.setDragImage) { var blank = document.createElement('canvas'); blank.width = blank.height = 1; e.dataTransfer.setDragImage(blank, 0, 0); }
            colGhost = document.createElement('div'); colGhost.className = 'pp-col-drag-ghost';
            colGhost.innerHTML = '<i class="bi bi-grip-vertical"></i> ' + (th.getAttribute('data-label') || (th.textContent || '').trim());
            document.body.appendChild(colGhost);
        });
        thead.addEventListener('dragover', function (e) {
            var th = e.target.closest('th[data-col]'); if (!th || th === dragTh) return; e.preventDefault();
            var r = th.getBoundingClientRect(); dropAfter = (e.clientX > r.left + r.width / 2); dropTh = th;
            clearOver(); th.classList.add(dropAfter ? 'pp-col-over-right' : 'pp-col-over-left');
        });
        document.addEventListener('dragover', function (e) { if (colGhost) { colGhost.style.left = (e.clientX + 14) + 'px'; colGhost.style.top = (e.clientY + 12) + 'px'; } });
        thead.addEventListener('drop', function (e) {
            if (!dragTh || !dropTh || dropTh === dragTh) return; e.preventDefault();
            var from = dragTh.getAttribute('data-col'), to = dropTh.getAttribute('data-col');
            var order = dataCols().map(function (t) { return t.getAttribute('data-col'); });
            order.splice(order.indexOf(from), 1);
            order.splice(order.indexOf(to) + (dropAfter ? 1 : 0), 0, from);
            applyOrder(order); saveS({ order: order });
        });
        thead.addEventListener('dragend', function () {
            if (dragTh) dragTh.classList.remove('pp-col-dragging');
            clearOver(); if (colGhost) { colGhost.remove(); colGhost = null; } dragTh = dropTh = null;
        });
    }

    /* ===== Resizable columns ===== */
    if (RESIZE) {
        table.classList.add('pp-resizable');
        var savedW = loadS().widths || {};
        var allTh = Array.prototype.slice.call(thead.rows[0].cells);
        var snap = allTh.map(function (th) { return th.offsetWidth; });   // snapshot BEFORE fixed layout so nothing reflows
        table.style.tableLayout = 'fixed';
        allTh.forEach(function (th, i) {
            var f = th.getAttribute('data-col');
            // The pinned actions rail is a constant width in CSS; leave it be so
            // neither a resize drag nor a stale saved width can squeeze it.
            if (th.classList.contains('ppd-grid-actions')) return;
            th.style.width = ((f && savedW[f]) || snap[i]) + 'px';
            if (!f) return;
            var grip = document.createElement('span');
            grip.className = 'pp-col-resizer';
            grip.setAttribute('draggable', 'false');
            grip.addEventListener('click', function (e) { e.stopPropagation(); });
            grip.setAttribute('title', 'Drag to resize · double-click to auto-fit');
            // Double-click the handle → auto-fit the column to its content.
            grip.addEventListener('dblclick', function (e) {
                e.preventDefault(); e.stopPropagation();
                var prev = th.style.width; th.style.width = 'auto';
                var natural = Math.max(60, Math.ceil(th.scrollWidth) + 4);
                th.style.width = natural + 'px';
                var w = loadS().widths || {}; w[f] = natural; saveS({ widths: w });
            });
            grip.addEventListener('mousedown', function (e) {
                e.preventDefault(); e.stopPropagation();
                if (REORDER) th.setAttribute('draggable', 'false');
                var startX = e.clientX, startW = th.offsetWidth;
                document.body.classList.add('pp-col-resizing');
                // Full-height guide line that tracks the cursor during the drag.
                var guide = document.createElement('div'); guide.className = 'pp-resize-guide';
                guide.style.left = e.clientX + 'px'; guide.style.height = document.documentElement.clientHeight + 'px';
                document.body.appendChild(guide);
                function mm(ev) { th.style.width = Math.max(60, startW + (ev.clientX - startX)) + 'px'; guide.style.left = ev.clientX + 'px'; }
                function mu() {
                    document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu);
                    document.body.classList.remove('pp-col-resizing');
                    if (guide) guide.remove();
                    if (REORDER) th.setAttribute('draggable', 'true');
                    var w = loadS().widths || {}; w[f] = th.offsetWidth; saveS({ widths: w });
                }
                document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
            });
            th.appendChild(grip);
        });
    }

    /* ===== Freeze / pin columns (design defaults + optional per-browser toggle) ===== */
    var _dp = DESIGN_PINS || { left: [], right: [] };
    var _hasDesignPins = (_dp.left && _dp.left.length) || (_dp.right && _dp.right.length);
    if (PINCOLS || _hasDesignPins) {
        table.classList.add('pp-pinnable');
        var cellFor = function (row, col) { return row.querySelector('[data-col="' + (window.CSS && CSS.escape ? CSS.escape(col) : col) + '"]'); };
        var cw = function (el) { return el ? el.getBoundingClientRect().width : 0; };
        // Saved (end-user) pins win; otherwise fall back to the Designer's per-column freeze.
        function currentPins() { var s = loadS(); if (s.pins) return { left: s.pins.left || [], right: s.pins.right || [] }; return { left: (_dp.left || []).slice(), right: (_dp.right || []).slice() }; }
        function cyclePin(col) {
            var p = currentPins();
            var iL = p.left.indexOf(col), iR = p.right.indexOf(col);
            if (iL < 0 && iR < 0) { p.left.push(col); }              // none -> left
            else if (iL >= 0) { p.left.splice(iL, 1); p.right.push(col); }  // left -> right
            else { p.right.splice(iR, 1); }                          // right -> none
            saveS({ pins: p }); applyPins();
        }
        function clearPins() {
            allRows().forEach(function (row) {
                Array.prototype.forEach.call(row.cells, function (c) {
                    c.classList.remove('pp-pinned', 'pp-pin-edge-left', 'pp-pin-edge-right');
                    if (c.style.position === 'sticky') { c.style.position = ''; c.style.left = ''; c.style.right = ''; }
                });
            });
        }
        function applyPins() {
            clearPins();
            var p = currentPins();
            var headCells = Array.prototype.slice.call(thead.rows[0].cells);
            var order = dataCols().map(function (t) { return t.getAttribute('data-col'); });
            var left = order.filter(function (c) { return p.left.indexOf(c) >= 0; });
            var right = order.filter(function (c) { return p.right.indexOf(c) >= 0; });
            // Leading offset = width of any select/details cells before the first data column.
            var lead = 0; for (var i = 0; i < headCells.length; i++) { if (headCells[i].getAttribute('data-col')) break; lead += cw(headCells[i]); }
            var acc = lead;
            left.forEach(function (col, idx) {
                var hth = thead.rows[0].querySelector('[data-col="' + (window.CSS && CSS.escape ? CSS.escape(col) : col) + '"]');
                var w = cw(hth), off = acc;
                allRows().forEach(function (row) { var c = cellFor(row, col); if (c) { c.classList.add('pp-pinned'); c.style.position = 'sticky'; c.style.left = off + 'px'; if (idx === left.length - 1) c.classList.add('pp-pin-edge-left'); } });
                acc += w;
            });
            // Trailing offset = width of the actions rail (cells after the last data column).
            var trail = 0; for (var j = headCells.length - 1; j >= 0; j--) { if (headCells[j].getAttribute('data-col')) break; trail += cw(headCells[j]); }
            var racc = trail;
            right.slice().reverse().forEach(function (col, idx) {
                var hth = thead.rows[0].querySelector('[data-col="' + (window.CSS && CSS.escape ? CSS.escape(col) : col) + '"]');
                var w = cw(hth), off = racc;
                allRows().forEach(function (row) { var c = cellFor(row, col); if (c) { c.classList.add('pp-pinned'); c.style.position = 'sticky'; c.style.right = off + 'px'; if (idx === right.length - 1) c.classList.add('pp-pin-edge-right'); } });
                racc += w;
            });
            // Reflect each header button's state.
            dataCols().forEach(function (th) {
                var col = th.getAttribute('data-col'), btn = th.querySelector('.pp-col-pin'); if (!btn) return;
                var st = p.left.indexOf(col) >= 0 ? 'left' : (p.right.indexOf(col) >= 0 ? 'right' : '');
                btn.className = 'pp-col-pin' + (st ? ' is-pinned' : '');
                btn.innerHTML = '<i class="bi bi-pin-angle' + (st ? '-fill' : '') + '"></i>';
                btn.setAttribute('title', st === 'left' ? 'Frozen left — click to freeze right' : (st === 'right' ? 'Frozen right — click to unfreeze' : 'Freeze this column'));
            });
        }
        // End-user pin toggle buttons only when the pinColumns capability is on;
        // design-time pins still apply (sticky) without any buttons.
        if (PINCOLS) dataCols().forEach(function (th) {
            if (th.querySelector('.pp-col-pin')) return;
            var b = document.createElement('button');
            b.type = 'button'; b.className = 'pp-col-pin'; b.setAttribute('aria-label', 'Freeze column');
            b.innerHTML = '<i class="bi bi-pin-angle"></i>';
            b.addEventListener('mousedown', function (e) { e.stopPropagation(); });
            b.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); cyclePin(th.getAttribute('data-col')); });
            th.appendChild(b);
        });
        applyPins();
        // Re-apply after column resize / reorder / window resize so offsets stay correct.
        window.addEventListener('resize', applyPins);
        table.addEventListener('mouseup', function () { setTimeout(applyPins, 0); });
        if (window.MutationObserver) { new MutationObserver(function () { applyPins(); }).observe(tbody, { childList: true }); }
    }

    /* ===== Click Actions ===== */
    if (CLICKS && CLICKS.length) {
        var caGuardSel = 'a, button, input, select, textarea, label, .ppd-grid-actions, .ppd-grid-select';
        function pageUrl(target, id) {
            var f = { view: 'view.php', edit: 'edit.php', add: 'add.php', print: 'print.php', export: 'export.php' }[target] || 'view.php';
            return f + (target === 'add' ? '' : ('?id=' + encodeURIComponent(id)));
        }
        // Navigate. forceNew (Ctrl/Cmd-click) always opens a new tab regardless of
        // the configured target, matching native link muscle-memory.
        function nav(url, openIn, forceNew) {
            if (!url) return;
            if (forceNew || openIn === 'newtab') window.open(url, '_blank');
            else if (openIn === 'popup') window.open(url, 'pp_popup', 'width=920,height=680,scrollbars=yes,resizable=yes');
            else window.location.href = url;
        }
        // Interpolate {field} tokens (and {id}) in a URL template from the row
        // record, each value URL-encoded. Unknown tokens resolve to ''.
        function interpUrl(tpl, rec, id) {
            return String(tpl || '').replace(/\{([^{}]+)\}/g, function (m, k) {
                k = String(k).trim();
                var v = (k === 'id') ? id : (rec && rec[k] !== undefined ? rec[k] : '');
                return encodeURIComponent(v == null ? '' : v);
            });
        }
        function rowHelper(tr) {
            var rec = {}; try { rec = JSON.parse(tr.getAttribute('data-pp-rec') || '{}'); } catch (e) {}
            return { id: tr.getAttribute('data-id'), data: rec, getFieldValue: function (n) { return rec[n]; }, fieldCell: function (n) { return tr.querySelector('td[data-col="' + n + '"]'); }, element: tr };
        }
        function tbI18n(k, d) { return (window.PP_SF_I18N || {})[k] || d; }
        // Lightweight single-row refresh: re-fetch the current list and swap just
        // this row's cells (server-rendered list stays the source of truth).
        function refreshRow(tr) {
            var id = tr.getAttribute('data-id');
            fetch(window.location.href, { credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
                try {
                    var esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
                    var fresh = new DOMParser().parseFromString(html, 'text/html').querySelector('tr[data-id="' + esc + '"]');
                    if (fresh) tr.innerHTML = fresh.innerHTML; else window.location.reload();
                } catch (e) { window.location.reload(); }
            }).catch(function () {});
        }
        function doRefresh(mode, tr) {
            if (mode === 'row') refreshRow(tr);
            else if (mode === 'grid' || mode === 'reload') window.location.reload();
        }
        // Apply structured server-result directives from the executeCode result
        // object: notify/toast, setCell, redirect, refresh/reload. Runs before the
        // author's Client After so custom JS can further react to the same result.
        function applyResult(result, row, tr) {
            if (!result || typeof result !== 'object') return;
            var n = result.notify || result.toast;
            if (n) {
                var msg = (typeof n === 'object') ? (n.message || n.text || '') : n;
                var type = (typeof n === 'object' && n.type) ? n.type : 'info';
                if (window.Swal && Swal.fire) Swal.fire({ toast: true, position: 'top-end', timer: 3200, timerProgressBar: true, showConfirmButton: false, icon: type, title: msg });
                else if (window.Runner && Runner.notify) Runner.notify(msg, type);
                else console.log('[notify]', msg);
            }
            if (result.setCell && typeof result.setCell === 'object') {
                Object.keys(result.setCell).forEach(function (f) {
                    var td = tr.querySelector('td[data-col="' + f + '"]'); if (td) td.innerHTML = result.setCell[f];
                });
            }
            if (result.redirect) { nav(String(result.redirect), result.openIn || 'current', false); return; }
            if (result.reload) { window.location.reload(); return; }
            if (result.refresh) doRefresh(String(result.refresh), tr);
        }
        // The actual action, after the onlyWhen + confirm gates have passed.
        function doAction(ca, tr, row, id, ev) {
            var forceNew = !!(ev && (ev.metaKey || ev.ctrlKey || ev.button === 1));
            if (ca.actionType === 'openPage') { nav(pageUrl(ca.target, id), ca.openIn, forceNew); return; }
            if (ca.actionType === 'openUrl') { nav(interpUrl(ca.url, row.data, id), ca.openIn, forceNew); return; }
            if (ca.actionType === 'selectRecord') { var cb = tr.querySelector('.ppd-select-row'); if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles: true })); } return; }
            if (ca.actionType === 'expandDetails') { var ex = tr.querySelector('.ppd-grid-details a, .ppd-grid-details button, [data-pp-md-popup]'); if (ex) ex.click(); return; }
            if (ca.actionType === 'executeCode') {
                var params = { id: id, __table: (document.body && document.body.getAttribute('data-pp-table')) || '' };
                try { if (ca.clientBefore) (new Function('row', 'params', ca.clientBefore))(row, params); } catch (e) { console.error('Click action (before):', e); }
                var after = function (result) {
                    applyResult(result, row, tr);
                    // A refresh set in the Designer applies even when no directive
                    // requested one (and is a no-op if a directive already navigated).
                    if (ca.refresh && ca.refresh !== 'none' && !(result && (result.redirect || result.reload || result.refresh))) doRefresh(ca.refresh, tr);
                    try { if (ca.clientAfter) (new Function('row', 'params', 'result', ca.clientAfter))(row, params, result || {}); } catch (e) { console.error('Click action (after):', e); }
                };
                // Server (PHP) half — runs via api/list-click.php (CSRF-guarded),
                // keyed by srvKey. No key → purely client-side, no round trip.
                if (ca.srvKey) {
                    var csrf = window.PP_CSRF || '';
                    fetch((window.PP_BASE || '') + '/api/list-click.php', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
                        credentials: 'same-origin',
                        body: JSON.stringify({ key: ca.srvKey, params: params, _token: csrf })
                    }).then(function (r) { return r.json(); }).then(function (j) {
                        if (j && j.commands && window.Runner && Runner.apply) { try { Runner.apply(j.commands); } catch (e) {} }
                        if (j && j.success === false && j.error) console.error('Click action (server):', j.error);
                        after(j && j.result !== undefined ? j.result : {});
                    }).catch(function (e) { console.error('Click action (server):', e); after({}); });
                } else {
                    after({});
                }
                return;
            }
        }
        // Gate an action through its optional "Only run when" condition and its
        // optional confirm dialog, then run it.
        function runAction(ca, tr, ev) {
            var id = tr.getAttribute('data-id');
            var row = rowHelper(tr);
            if (ca.onlyWhen) {
                try { if (!(new Function('row', 'data', 'return (' + ca.onlyWhen + ');'))(row, row.data)) return; }
                catch (e) { console.error('Click action (onlyWhen):', e); }
            }
            var go = function () { doAction(ca, tr, row, id, ev); };
            if (ca.confirm) {
                if (window.Swal && Swal.fire) {
                    Swal.fire({ title: ca.confirm, icon: 'question', showCancelButton: true, confirmButtonText: tbI18n('ok', 'OK'), cancelButtonText: tbI18n('cancel', 'Cancel') })
                        .then(function (res) { if (res && res.isConfirmed) go(); });
                    return;
                }
                if (!window.confirm(ca.confirm)) return;
            }
            go();
        }
        var rowActions = CLICKS.filter(function (c) { return c.field === 'row'; });
        var fieldActions = {};
        CLICKS.forEach(function (c) { if (c.field !== 'row') (fieldActions[c.field] = fieldActions[c.field] || []).push(c); });
        if (rowActions.length) table.classList.add('pp-click-row');
        Object.keys(fieldActions).forEach(function (f) { tbody.querySelectorAll('td[data-col="' + f + '"]').forEach(function (td) { td.classList.add('pp-click-cell'); }); });
        // Resolve the action for a (cell|row) at a given trigger ('single'|'double').
        function actionFor(tr, cell, trig) {
            if (cell) {
                var f = cell.getAttribute('data-col'), list = fieldActions[f];
                if (list) { for (var i = 0; i < list.length; i++) { if ((list[i].on || 'single') === trig) return list[i]; } }
            }
            for (var j = 0; j < rowActions.length; j++) { if ((rowActions[j].on || 'single') === trig) return rowActions[j]; }
            return null;
        }
        function selecting() { var s = window.getSelection && window.getSelection(); return !!(s && String(s).length > 0); }
        var hasDouble = CLICKS.some(function (c) { return (c.on || 'single') === 'double'; });
        var clickTimer = null;
        tbody.addEventListener('click', function (e) {
            if (e.target.closest(caGuardSel) || selecting()) return;
            var tr = e.target.closest('tr[data-id]'); if (!tr) return;
            var ca = actionFor(tr, e.target.closest('td[data-col]'), 'single'); if (!ca) return;
            if (hasDouble) {   // delay a touch so a double-click can pre-empt the single
                if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
                clickTimer = setTimeout(function () { clickTimer = null; runAction(ca, tr, e); }, 220);
            } else { runAction(ca, tr, e); }
        });
        if (hasDouble) tbody.addEventListener('dblclick', function (e) {
            if (e.target.closest(caGuardSel)) return;
            if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
            var tr = e.target.closest('tr[data-id]'); if (!tr) return;
            var ca = actionFor(tr, e.target.closest('td[data-col]'), 'double'); if (!ca) return;
            runAction(ca, tr, e);
        });
        // A whole-row click action also fires on ENTER for keyboard users. Roving
        // tabindex + arrow navigation come from the grid-interaction bundle below,
        // which also reserves SPACE for toggling row selection.
        if (rowActions.some(function (c) { return (c.on || 'single') === 'single'; })) {
            tbody.addEventListener('keydown', function (e) {
                if (e.key !== 'Enter') return;
                var tr = e.target && e.target.closest ? e.target.closest('tr[data-id]') : null;
                if (!tr || e.target !== tr || e.target.closest(caGuardSel)) return;
                var ca = actionFor(tr, null, 'single'); if (!ca) return;
                e.preventDefault(); runAction(ca, tr, e);
            });
        }
    }

    /* ===== Grid interactions: shift-select · keyboard nav · copy ===== */
    (function () {
        function dataRows() {
            return Array.prototype.filter.call(tbody.querySelectorAll('tr[data-id]'), function (r) {
                return !r.classList.contains('pp-inline-add-row') && !r.classList.contains('pp-md-exp');
            });
        }
        function selBoxes() { return Array.prototype.slice.call(tbody.querySelectorAll('.ppd-select-row')); }

        // Shift-click a row checkbox → select/deselect the whole range from the
        // last-clicked box (spreadsheet behaviour).
        var lastIdx = -1;
        tbody.addEventListener('click', function (e) {
            var cb = e.target.closest ? e.target.closest('.ppd-select-row') : null;
            if (!cb) return;
            var list = selBoxes(), idx = list.indexOf(cb);
            if (e.shiftKey && lastIdx > -1 && idx > -1) {
                var a = Math.min(lastIdx, idx), b = Math.max(lastIdx, idx), on = cb.checked;
                for (var k = a; k <= b; k++) { if (list[k].checked !== on) { list[k].checked = on; list[k].dispatchEvent(new Event('change', { bubbles: true })); } }
            }
            if (idx > -1) lastIdx = idx;
        });

        // Roving tabindex + arrow-key row navigation. Space toggles the row's
        // selection; Enter is handled by the click-action keydown above (if any).
        var rr = dataRows();
        rr.forEach(function (r, i) { r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
        function focusRow(i) {
            var list = dataRows(); if (!list.length) return;
            i = Math.max(0, Math.min(i, list.length - 1));
            list.forEach(function (r, k) { r.setAttribute('tabindex', k === i ? '0' : '-1'); });
            list[i].focus();
        }
        tbody.addEventListener('keydown', function (e) {
            var tr = e.target && e.target.closest ? e.target.closest('tr[data-id]') : null;
            if (!tr || e.target !== tr) return;
            var list = dataRows(), i = list.indexOf(tr);
            if (e.key === 'ArrowDown') { e.preventDefault(); focusRow(i + 1); }
            else if (e.key === 'ArrowUp') { e.preventDefault(); focusRow(i - 1); }
            else if (e.key === 'Home') { e.preventDefault(); focusRow(0); }
            else if (e.key === 'End') { e.preventDefault(); focusRow(list.length - 1); }
            else if (e.key === ' ' || e.key === 'Spacebar') {
                var cb = tr.querySelector('.ppd-select-row');
                if (cb) { e.preventDefault(); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles: true })); lastIdx = selBoxes().indexOf(cb); }
            }
        });

        // Copy the selected rows to the clipboard as TSV (header + values), so a
        // filtered selection can be pasted straight into a spreadsheet.
        window.ppCopySelection = function () {
            var sel = selBoxes().filter(function (c) { return c.checked; });
            if (!sel.length) { if (window.Swal) Swal.fire({ toast: true, position: 'top-end', timer: 1600, showConfirmButton: false, icon: 'info', title: 'Select some rows first' }); return; }
            var cols = Array.prototype.slice.call(thead.rows[0].cells).filter(function (th) { return th.getAttribute('data-col'); });
            var clean = function (s) { return String(s == null ? '' : s).replace(/\\s+/g, ' ').trim(); };
            var lines = [cols.map(function (th) { return clean(th.textContent); }).join('\\t')];
            sel.forEach(function (c) {
                var tr = c.closest('tr');
                lines.push(cols.map(function (th) { var td = tr.querySelector('td[data-col="' + th.getAttribute('data-col') + '"]'); return clean(td ? td.textContent : ''); }).join('\\t'));
            });
            var text = lines.join('\\n');
            var done = function () { if (window.Swal) Swal.fire({ toast: true, position: 'top-end', timer: 1800, showConfirmButton: false, icon: 'success', title: sel.length + ' row(s) copied' }); };
            if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(fallback);
            else fallback();
            function fallback() { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) {} ta.remove(); }
        };
    })();

    /* ===== Row grouping (client-side, current page) ===== */
    if (GROUPBY) (function () {
        var gcol = GROUPBY;
        var cssEsc = function (s) { return window.CSS && CSS.escape ? CSS.escape(s) : s; };
        var q = function (root, col) { return root.querySelector('[data-col="' + cssEsc(col) + '"]'); };
        if (!thead.rows[0] || !q(thead.rows[0], gcol)) return;              // group column not on the page
        var colCount = thead.rows[0].cells.length;
        var dcols = dataCols().map(function (t) { return t.getAttribute('data-col'); });
        var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
        var numOf = function (t) { var n = parseFloat(String(t).replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n; };
        var fmt = function (n) { return (Math.round(n * 100) / 100).toLocaleString(); };
        var rows = Array.prototype.slice.call(tbody.rows).filter(function (r) {
            return q(r, gcol) && !r.classList.contains('pp-inline-add-row') && !r.classList.contains('pp-md-exp');
        });
        if (rows.length < 2) return;
        var order = [], byKey = {};
        rows.forEach(function (r) {
            var key = (q(r, gcol).textContent || '').trim();
            if (!(key in byKey)) { byKey[key] = []; order.push(key); }
            byKey[key].push(r);
        });
        if (order.length < 2) return;                                      // only one group — nothing to do
        table.classList.add('pp-grouped');
        order.forEach(function (key) {
            var grp = byKey[key];
            var hr = document.createElement('tr');
            hr.className = 'pp-group-row';
            var isCol = !!(loadS().groupsCollapsed || {})[key];
            hr.innerHTML = '<td colspan="' + colCount + '"><button type="button" class="pp-group-toggle" aria-label="Toggle group"><i class="bi bi-chevron-' + (isCol ? 'right' : 'down') + '"></i></button>'
                + '<span class="pp-group-label">' + (key === '' ? '—' : esc(key)) + '</span><span class="pp-group-count">' + grp.length + '</span></td>';
            tbody.appendChild(hr);
            grp.forEach(function (r) { tbody.appendChild(r); });
            // Subtotal row: only for columns the developer configured as footer
            // aggregates (GROUP_AGG: col -> fn), using that same function.
            var totals = {}, hasAgg = false;
            Object.keys(GROUP_AGG || {}).forEach(function (cn) {
                if (cn === gcol) return;
                var fn = GROUP_AGG[cn], vals = [];
                grp.forEach(function (r) { var c = q(r, cn); if (c) { var v = numOf(c.textContent); if (v !== null) vals.push(v); } });
                if (fn === 'count') { totals[cn] = grp.length; hasAgg = true; return; }
                if (!vals.length) return;
                var out;
                if (fn === 'sum') out = vals.reduce(function (a, b) { return a + b; }, 0);
                else if (fn === 'avg') out = vals.reduce(function (a, b) { return a + b; }, 0) / vals.length;
                else if (fn === 'min') out = Math.min.apply(null, vals);
                else if (fn === 'max') out = Math.max.apply(null, vals);
                else out = vals.reduce(function (a, b) { return a + b; }, 0);
                totals[cn] = out; hasAgg = true;
            });
            var sr = null;
            if (hasAgg) {
                sr = document.createElement('tr'); sr.className = 'pp-group-subtotal';
                var cells = '';
                Array.prototype.forEach.call(thead.rows[0].cells, function (th) {
                    var cn = th.getAttribute('data-col');
                    if (!cn) { cells += '<td></td>'; return; }
                    cells += '<td data-col="' + esc(cn) + '">' + (cn in totals ? '<strong>' + esc(fmt(totals[cn])) + '</strong>' : '') + '</td>';
                });
                sr.innerHTML = cells; tbody.appendChild(sr);
            }
            var setCollapsed = function (v) {
                grp.forEach(function (r) { r.style.display = v ? 'none' : ''; });
                if (sr) sr.style.display = v ? 'none' : '';
                hr.querySelector('i').className = 'bi bi-chevron-' + (v ? 'right' : 'down');
            };
            if (isCol) setCollapsed(true);
            hr.querySelector('.pp-group-toggle').addEventListener('click', function () {
                var s = loadS().groupsCollapsed || {}; var now = !s[key]; s[key] = now; saveS({ groupsCollapsed: s });
                setCollapsed(now);
            });
        });
    })();

    /* ===== Saved views (per browser): named presets of search + sort + filters + columns + view mode ===== */
    if (SAVEDVIEWS) (function () {
        var VKEY = 'ppViews:' + TABLE, AKEY = 'ppViewsActive:' + TABLE;
        var viewKey = 'ppView:' + (document.body.getAttribute('data-pp-table') || location.pathname);
        var load = function () { try { return JSON.parse(localStorage.getItem(VKEY) || '[]'); } catch (e) { return []; } };
        var store = function (v) { try { localStorage.setItem(VKEY, JSON.stringify(v)); } catch (e) {} };
        var activeName = function () { try { return localStorage.getItem(AKEY) || ''; } catch (e) { return ''; } };
        var setActive = function (n) { try { n ? localStorage.setItem(AKEY, n) : localStorage.removeItem(AKEY); } catch (e) {} };
        var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
        var snapshot = function () { return { qs: location.search || '', list: localStorage.getItem(LSKEY) || '', view: localStorage.getItem(viewKey) || '' }; };
        function applyView(v) {
            if (!v) return;
            try {
                if (v.list) localStorage.setItem(LSKEY, v.list); else localStorage.removeItem(LSKEY);
                if (v.view) localStorage.setItem(viewKey, v.view); else localStorage.removeItem(viewKey);
            } catch (e) {}
            setActive(v.name);
            location.href = location.pathname + (v.qs || '');
        }
        var toolbar = document.querySelector('.pp-toolbar') || document.querySelector('.ppd-grid-toolbar');
        if (!toolbar) return;
        var zone = toolbar.querySelector('.pp-tb-left') || toolbar;
        var wrap = document.createElement('div'); wrap.className = 'pp-views';
        // Icon-only Saved-views button (tooltip only) — compact; the active view
        // name lives in .pp-views-cur (kept for JS/aria, visually hidden) and the
        // active state is shown by the accent .pp-views-on style.
        wrap.innerHTML = '<button type="button" class="ppd-grid-toolbar-btn pp-views-btn" data-pp-tip="Saved views" aria-label="Saved views"><i class="bi bi-bookmark-star"></i><span class="pp-views-cur"></span></button><div class="pp-views-menu" hidden></div>';
        zone.insertBefore(wrap, zone.firstChild);
        var btn = wrap.querySelector('.pp-views-btn'), menu = wrap.querySelector('.pp-views-menu'), cur = wrap.querySelector('.pp-views-cur');
        function render() {
            var views = load(), act = activeName();
            cur.textContent = act || 'Views';
            btn.classList.toggle('pp-views-on', !!act);   // NOT 'is-active' — that class triggers the generic toolbar primary-fill (primary-on-primary)
            var html = '';
            if (!views.length) html += '<div class="pp-views-empty">No saved views yet</div>';
            views.forEach(function (v, i) {
                html += '<div class="pp-views-item' + (v.name === act ? ' is-active' : '') + '">'
                    + '<span class="pp-views-apply" data-i="' + i + '"><i class="bi bi-' + (v.name === act ? 'check2-circle' : 'bookmark') + '"></i> ' + esc(v.name) + '</span>'
                    + '<button type="button" class="pp-views-del" data-i="' + i + '" title="Delete view"><i class="bi bi-x"></i></button></div>';
            });
            html += '<div class="pp-views-foot">';
            if (act) html += '<button type="button" class="pp-views-clear"><i class="bi bi-arrow-counterclockwise"></i> Reset to default</button>';
            html += '<button type="button" class="pp-views-save"><i class="bi bi-plus-lg"></i> Save current view…</button></div>';
            menu.innerHTML = html;
        }
        render();
        btn.addEventListener('click', function (e) { e.stopPropagation(); menu.hidden = !menu.hidden; });
        document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) menu.hidden = true; });
        menu.addEventListener('click', function (e) {
            var ap = e.target.closest('.pp-views-apply'); if (ap) { applyView(load()[+ap.getAttribute('data-i')]); return; }
            var del = e.target.closest('.pp-views-del'); if (del) { e.stopPropagation(); var vs = load(); var d = vs[+del.getAttribute('data-i')]; if (d && d.name === activeName()) setActive(''); vs.splice(+del.getAttribute('data-i'), 1); store(vs); render(); return; }
            if (e.target.closest('.pp-views-clear')) { setActive(''); try { localStorage.removeItem(LSKEY); localStorage.removeItem(viewKey); } catch (x) {} location.href = location.pathname; return; }
            if (e.target.closest('.pp-views-save')) {
                var doSave = function (name) {
                    name = (name || '').trim(); if (!name) return;
                    var vs = load(), snap = snapshot(); snap.name = name;
                    var ix = vs.map(function (v) { return v.name; }).indexOf(name);
                    if (ix >= 0) vs[ix] = snap; else vs.push(snap);
                    store(vs); setActive(name); render(); menu.hidden = true;
                };
                if (window.Swal) { Swal.fire({ title: 'Save current view', input: 'text', inputPlaceholder: 'e.g. Active · sorted by date', showCancelButton: true, confirmButtonText: 'Save' }).then(function (r) { if (r.isConfirmed) doSave(r.value); }); }
                else { doSave(window.prompt('Name this view:')); }
            }
        });
    })();

    /* ===== Kanban / board view: group records into columns by a field; drag to change it ===== */
    if (KANBAN_FIELD) (function () {
        var field = KANBAN_FIELD;
        var cssEsc = function (s) { return window.CSS && CSS.escape ? CSS.escape(s) : s; };
        if (!thead.rows[0] || !thead.rows[0].querySelector('[data-col="' + cssEsc(field) + '"]')) return;
        var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
        var wrap = table.closest('.ppd-grid-table-wrap') || table.parentNode;
        var viewKey = 'ppView:' + (document.body.getAttribute('data-pp-table') || location.pathname);
        var board = document.createElement('div'); board.className = 'pp-kanban'; board.hidden = true;
        wrap.parentNode.insertBefore(board, wrap.nextSibling);
        // Ensure a view-toggle group exists (create one with a Table button when the
        // Cards page-type is off), then add a Board button. It uses data-pp-board so
        // the cards-view handler ignores it — this block manages board visibility.
        var toggleGroup = document.querySelector('.ppd-view-toggle');
        if (!toggleGroup) {
            var tb = document.querySelector('.pp-tb-right') || document.querySelector('.pp-toolbar') || document.querySelector('.ppd-grid-toolbar');
            if (!tb) return;
            toggleGroup = document.createElement('div'); toggleGroup.className = 'ppd-view-toggle';
            toggleGroup.innerHTML = '<button type="button" class="ppd-grid-toolbar-btn is-active" data-pp-view="table" data-pp-tip="Table view" aria-label="Table view"><i class="bi bi-table"></i></button>';
            tb.appendChild(toggleGroup);
        }
        var boardBtn = document.createElement('button');
        boardBtn.type = 'button'; boardBtn.className = 'ppd-grid-toolbar-btn'; boardBtn.setAttribute('data-pp-board', '1');
        boardBtn.setAttribute('data-pp-tip', 'Board view'); boardBtn.setAttribute('aria-label', 'Board view'); boardBtn.innerHTML = '<i class="bi bi-kanban"></i>';
        toggleGroup.appendChild(boardBtn);
        var dcolNames = dataCols().map(function (t) { return t.getAttribute('data-col'); });
        var labelOf = function (c) { var th = thead.rows[0].querySelector('[data-col="' + cssEsc(c) + '"]'); return th ? (th.getAttribute('data-label') || (th.textContent || '').trim()) : c; };
        var cellText = function (tr, c) { var td = tr.querySelector('[data-col="' + cssEsc(c) + '"]'); return td ? (td.textContent || '').trim() : ''; };
        var isId = function (c) { return c === 'id' || /(^|_)id$/i.test(c); };
        function pickTitleCol(sample) {
            var cands = dcolNames.filter(function (c) { return c !== field; });
            var named = cands.filter(function (c) { return /name|title|label|subject|full/i.test(c); })[0];
            if (named) return named;
            var textCol = cands.filter(function (c) { if (isId(c)) return false; var t = sample ? cellText(sample, c) : ''; return t && isNaN(parseFloat(t.replace(/[^0-9.\-]/g, ''))); })[0];
            return textCol || cands.filter(function (c) { return !isId(c); })[0] || cands[0];
        }
        var dragCard = null;
        function recount() { board.querySelectorAll('.pp-kanban-col').forEach(function (c) { var el = c.querySelector('.pp-kanban-num'); if (el) el.textContent = c.querySelectorAll('.pp-kanban-card').length; }); }
        function wireDnD() {
            board.querySelectorAll('.pp-kanban-card').forEach(function (card) {
                card.addEventListener('dragstart', function () { dragCard = card; card.classList.add('is-dragging'); });
                card.addEventListener('dragend', function () { card.classList.remove('is-dragging'); dragCard = null; board.querySelectorAll('.pp-kanban-col').forEach(function (c) { c.classList.remove('is-over'); }); });
            });
            board.querySelectorAll('.pp-kanban-col').forEach(function (col) {
                col.addEventListener('dragover', function (e) { e.preventDefault(); col.classList.add('is-over'); });
                col.addEventListener('dragleave', function () { col.classList.remove('is-over'); });
                col.addEventListener('drop', function (e) {
                    e.preventDefault(); col.classList.remove('is-over');
                    if (!dragCard) return;
                    var oldCol = dragCard.closest('.pp-kanban-col'); if (oldCol === col) return;
                    var id = dragCard.getAttribute('data-id'), newVal = col.getAttribute('data-kval'), moving = dragCard;
                    col.querySelector('.pp-kanban-body').appendChild(moving); recount();
                    var data = {}; data[field] = newVal;
                    fetch((window.PP_BASE || '') + '/api/update-selected.php', {
                        method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
                        body: JSON.stringify({ table: TABLE, ids: [id], data: data })
                    }).then(function (r) { return r.json(); }).then(function (j) {
                        if (!j || !j.success) { oldCol.querySelector('.pp-kanban-body').appendChild(moving); recount(); alert((j && j.error) || 'Could not move the card.'); }
                        else { var td = tbody.querySelector('tr[data-id="' + id + '"] [data-col="' + cssEsc(field) + '"]'); if (td) td.textContent = newVal; }
                    }).catch(function () { oldCol.querySelector('.pp-kanban-body').appendChild(moving); recount(); alert('Could not move the card.'); });
                });
            });
        }
        function buildBoard() {
            var rows = Array.prototype.slice.call(tbody.rows).filter(function (r) {
                return r.getAttribute('data-id') && !r.classList.contains('pp-inline-add-row') && !r.classList.contains('pp-md-exp') && !r.classList.contains('pp-group-row') && !r.classList.contains('pp-group-subtotal');
            });
            var titleCol = pickTitleCol(rows[0]);
            var detailCols = dcolNames.filter(function (c) { return c !== field && c !== titleCol && !isId(c); }).slice(0, 2);
            var cols = [], byVal = {};
            rows.forEach(function (r) { var v = cellText(r, field); if (!(v in byVal)) { byVal[v] = []; cols.push(v); } byVal[v].push(r); });
            board.innerHTML = '';
            cols.forEach(function (v) {
                var col = document.createElement('div'); col.className = 'pp-kanban-col'; col.setAttribute('data-kval', v);
                var body = '';
                byVal[v].forEach(function (r) {
                    var id = r.getAttribute('data-id');
                    var det = detailCols.map(function (c) { var t = cellText(r, c); return t ? '<div class="pp-kanban-cd"><span>' + esc(labelOf(c)) + '</span> ' + esc(t) + '</div>' : ''; }).join('');
                    body += '<div class="pp-kanban-card" draggable="true" data-id="' + esc(id) + '"><div class="pp-kanban-ct">' + (esc(cellText(r, titleCol)) || ('#' + esc(id))) + '</div>' + det + '</div>';
                });
                col.innerHTML = '<div class="pp-kanban-head"><span class="pp-kanban-title">' + (v === '' ? '—' : esc(v)) + '</span><span class="pp-kanban-num">' + byVal[v].length + '</span></div><div class="pp-kanban-body">' + body + '</div>';
                board.appendChild(col);
            });
            wireDnD();
        }
        function showKanban(on) {
            board.hidden = !on;
            if (on) { buildBoard(); wrap.style.display = 'none'; var c = document.getElementById('ppCards'); if (c) c.hidden = true; }
            else { wrap.style.display = ''; }
        }
        function activateBoard(on) {
            if (on) toggleGroup.querySelectorAll('[data-pp-view]').forEach(function (b) { b.classList.remove('is-active'); });
            boardBtn.classList.toggle('is-active', on);
        }
        boardBtn.addEventListener('click', function () { activateBoard(true); try { localStorage.setItem(viewKey, 'kanban'); } catch (e) {} if (window.ppSyncViewUrl) window.ppSyncViewUrl(); showKanban(true); });
        toggleGroup.querySelectorAll('[data-pp-view]').forEach(function (b) { b.addEventListener('click', function () { activateBoard(false); showKanban(false); }); });
        try { if (localStorage.getItem(viewKey) === 'kanban') { activateBoard(true); showKanban(true); } } catch (e) {}
    })();
})();
</script>
<script>
/* ── Add Settings: "Show Add page in popup" — iframe modal for the Add form ──
   Draggable by its header, auto-grows to the form's height (capped), the header
   mirrors the page title, and Cancel/X/backdrop/Esc close it. */
(function () {
    var modal = document.getElementById('ppFormModal');
    if (!modal) return;
    var dialog = modal.querySelector('.pp-form-modal-dialog');
    var frame = modal.querySelector('.pp-form-modal-frame');
    var titleEl = modal.querySelector('.pp-form-modal-title');
    var head = modal.querySelector('.pp-form-modal-head');
    var defTitle = titleEl ? titleEl.textContent : '';
    var ox = 0, oy = 0;
    function place() { dialog.style.transform = 'translate(' + ox + 'px,' + oy + 'px)'; }
    function frameMax() { return Math.max(220, Math.round(window.innerHeight * 0.82) - (head ? head.offsetHeight : 48)); }
    function openModal(url) {
        ox = 0; oy = 0; dialog.style.transform = ''; if (titleEl) titleEl.textContent = defTitle;
        frame.style.height = Math.min(frameMax(), Math.round(window.innerHeight * 0.6)) + 'px';
        frame.src = url + (url.indexOf('?') > -1 ? '&' : '?') + 'pp_modal=1';
        modal.hidden = false; document.body.style.overflow = 'hidden';
    }
    function closeModal() { modal.hidden = true; frame.src = 'about:blank'; document.body.style.overflow = ''; }
    // Casual dismissals (backdrop / Esc / ✕) warn first IF the form has unsaved
    // edits; a clean, untouched form closes instantly. Cancel skips the prompt.
    function isDirty() { try { return !!(frame.contentWindow && frame.contentWindow.ppFormDirty); } catch (e) { return false; } }
    function tryClose() {
        if (!isDirty()) { closeModal(); return; }
        if (window.Swal) {
            Swal.fire({
                title: 'Discard unsaved changes?',
                text: 'Anything you entered in this form will be lost.',
                icon: 'warning', showCancelButton: true,
                confirmButtonText: 'Discard', cancelButtonText: 'Keep editing',
                confirmButtonColor: '#dc2626', reverseButtons: true
            }).then(function (r) { if (r.isConfirmed) closeModal(); });
        } else if (window.confirm('Discard unsaved changes?')) {
            closeModal();
        }
    }
    document.addEventListener('click', function (e) {
        var trg = e.target.closest('[data-pp-form-modal]');
        if (trg) { e.preventDefault(); openModal(trg.getAttribute('data-pp-form-modal')); return; }
        if (e.target === modal || e.target.closest('.pp-form-modal-x')) tryClose();
    });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !modal.hidden) tryClose(); });
    // Drag by the header (ignore the close button)
    if (head) head.addEventListener('mousedown', function (e) {
        if (e.target.closest('.pp-form-modal-x')) return;
        e.preventDefault();
        var sx = e.clientX, sy = e.clientY, bx = ox, by = oy;
        head.classList.add('is-dragging');
        function mm(ev) { ox = bx + (ev.clientX - sx); oy = by + (ev.clientY - sy); place(); }
        function mu() { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); head.classList.remove('is-dragging'); }
        document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
    });
    window.addEventListener('message', function (e) {
        var d = e.data || {};
        if (d.ppFormModalTitle && titleEl) { titleEl.textContent = d.ppFormModalTitle; return; }
        if (d.ppFormModalHeight) { frame.style.height = Math.min(d.ppFormModalHeight, frameMax()) + 'px'; return; }
        if (!d.ppFormModal) return;
        if (d.ppFormModal === 'close') { closeModal(); return; }
        if (d.ppFormModal === 'navigate' && d.url) { window.location.href = d.url; return; }
        closeModal(); window.location.reload();   // close-reload
    });
})();
</script>
$setFilterScript
<?php
// Real edit-control HTML per field so inline-edit / spreadsheet / inline-add
// mount the actual plugin control (date pickers, dropdowns, lookups, …) instead
// of a plain input. Rendered at runtime so settings + i18n apply.
\$__ppEditors = [];
\$__ppNdpPairs = [];
if (class_exists('\\\\PpPhp\\\\Core\\\\Designer\\\\FieldControl\\\\FieldControlRuntime')) {
    foreach (\$fields as \$__ef) {
        \$__ec = \$__ef['editControl'] ?? '';
        if (\$__ec === '') continue;
        try { \$__ppEditors[\$__ef['name']] = \\PpPhp\\Core\\Designer\\FieldControl\\FieldControlRuntime::render('edit', \$__ec, \$__ef['name'], '', (array) (\$__ef['editSettings'] ?? []))['html']; } catch (\\Throwable \$e) {}
        // Nepali Date Picker double-mode pairing, indexed by BOTH fields so the
        // spreadsheet can convert whichever cell (BS or AD) is edited.
        if (\$__ec === 'NepaliDatePicker') {
            \$__es = (array) (\$__ef['editSettings'] ?? []);
            \$__en = preg_replace('/[^A-Za-z0-9_]/', '', (string) (\$__es['englishField'] ?? ''));
            if ((\$__es['pairMode'] ?? 'single') === 'double' && \$__en !== '') {
                \$__pair = ['bs' => \$__ef['name'], 'ad' => \$__en, 'bsFmt' => (\$__es['dateFormat'] ?? 'YYYY-MM-DD'), 'adFmt' => (\$__es['englishFormat'] ?? 'YYYY-MM-DD')];
                \$__ppNdpPairs[\$__ef['name']] = \$__pair;
                \$__ppNdpPairs[\$__en] = \$__pair;
            }
        }
    }
}
?>
<script>window.PP_INLINE_EDITORS = <?= json_encode(\$__ppEditors, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
window.PP_NDP_PAIRS = <?= json_encode(\$__ppNdpPairs, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.js"></script>$listPluginJs
<script>
// Themed date/time pickers for the list page (filters + inline-add). Exposed
// so app.js can (re)enhance rows it reveals dynamically.
window.ppInitPickers = function (root) {
    if (!window.flatpickr) return;
    (root || document).querySelectorAll('[data-pp-picker]').forEach(function (el) {
        if (el._flatpickr) return;
        var kind = el.getAttribute('data-pp-picker');
        // Stored value stays MySQL-safe (Y-m-d); the chosen format only changes display.
        var fmt = el.getAttribute('data-pp-format');
        var f2 = fmt ? fmt.replace(/YYYY/g, 'Y').replace(/MM/g, 'm').replace(/DD/g, 'd') : '';
        var afmt = kind === 'datetime' ? 'M j, Y — H:i' : 'M j, Y';
        if (f2 && kind !== 'time') afmt = kind === 'datetime' ? (f2 + ' — H:i') : f2;
        // TimePicker options (data-pp-*): default 24-hour; opt-in seconds / minute step.
        var t24 = el.getAttribute('data-pp-time24') !== '0';
        var secs = el.getAttribute('data-pp-seconds') === '1';
        var minInc = parseInt(el.getAttribute('data-pp-step'), 10);
        var tfmt = secs ? 'H:i:S' : 'H:i';
        var pkOpts = {
            dateFormat: kind === 'time' ? tfmt : (kind === 'datetime' ? ('Y-m-d ' + tfmt) : 'Y-m-d'),
            enableTime: kind !== 'date', noCalendar: kind === 'time',
            altInput: kind !== 'time', altInputClass: 'pp-input',
            altFormat: afmt,
            time_24hr: t24, enableSeconds: secs, allowInput: true, disableMobile: false
        };
        if (minInc > 0) pkOpts.minuteIncrement = minInc;
        var inst = flatpickr(el, pkOpts);
        if (el.required && inst.altInput) { inst.altInput.required = true; el.required = false; }
    });
};
window.ppInitPickers(document);
</script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/js/events.js"></script>
</body></html>
PHP;

        // Icon-font trimming (perf): the list markup uses Bootstrap Icons (bi-*)
        // almost exclusively. Drop the FontAwesome (~868KB of webfonts) and
        // Material Symbols <link>s when neither this page NOR any project config
        // references their classes (FA → `fa-`, Material → `material-symbols`;
        // both always present when used). A false positive only keeps a font;
        // never drops a used glyph. Runs before asset-versioning appends ?v=.
        $__icons = $this->iconLibsUsed();
        // Match the CLASS token `material-symbols-<variant>` — NOT bare
        // "material-symbols", which also appears in the stylesheet's own href.
        if (!$__icons['material'] && strpos($content, 'material-symbols-') === false) {
            $content = str_replace('<link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css">', '', $content);
        }
        if (!$__icons['fa'] && strpos($content, 'fa-') === false) {
            foreach (['fontawesome.min', 'solid.min', 'regular.min'] as $__faFile) {
                $content = str_replace('<link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/' . $__faFile . '.css">', '', $content);
            }
        }
        $path = $dir . '/list.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/list.php");

        $written = [$path];
        // export.php is only shipped when export is permitted — a denied table
        // must not leave a working endpoint behind (and a stale one is removed).
        if ($exportFormats !== []) {
            $written[] = $this->writeTableExportPage($dir, $tableName, $tableLabel, $fields, $fieldNamesStr, $exportFormats);
        } else {
            @unlink($dir . '/export.php');
            $this->log("Export disabled for $tableName — export.php not generated");
        }
        // print.php likewise ships only when the Print page type is selected.
        if (in_array('print', $this->pageTypesFor($tableName), true)) {
            $written[] = $this->writeTablePrintPage($dir, $tableName, $tableLabel, $fields, $fieldNamesStr);
        } else {
            @unlink($dir . '/print.php');
        }
        // Real client-side PDF page — only when the 'pdf' page-type is enabled.
        if (in_array('pdf', $this->pageTypesFor($tableName), true)) {
            $written[] = $this->writeTablePdfPage($dir, $tableName, $tableLabel, $fields, $fieldNamesStr);
        } else {
            @unlink($dir . '/pdf.php');
        }
        return $written;
    }

    /**
     * export.php — CSV / Excel / Word download of the (filtered) list.
     *
     * @param list<string> $allowedFormats formats permitted by the Pages module;
     *        baked into the page as a whitelist so a hand-typed ?format= cannot
     *        reach a format the project disabled.
     */
    /**
     * export.php — a THIN bootstrap over core/DataOps.
     *
     * Serves both roles from one endpoint: `?format=…` downloads immediately
     * (toolbar menu, export-selected), while a bare request renders the Export
     * screen (Records → Fields → Format). All behaviour lives in ExportPage +
     * ExportRunner + FormatRegistry, shipped by copyCore().
     *
     * @param list<string> $allowedFormats formats permitted by the Pages module
     */
    private function writeTableExportPage(string $dir, string $tableName, string $tableLabel, array $fields, string $fieldNamesStr, array $allowedFormats = ['csv']): string
    {
        $cols = [];
        foreach ($fields as $f) $cols[(string) $f['name']] = (string) ($f['label'] ?? $f['name']);

        $design        = $this->designFor($tableName, 'export');
        $colsExport    = var_export($cols, true);
        $allowedExport = var_export(array_values($allowedFormats), true);
        $lookupExport  = var_export($this->lookupFieldsFor($tableName), true);
        $optionMapsExport = var_export($this->optionMapsFor($tableName), true);
        $headersExport = var_export($this->exportIncludeHeaders($tableName), true);
        $blocksExport  = var_export($this->exportDesignBlocks($design), true);
        $chromeExport  = var_export($this->pageChromeHtml($design), true);
        $cssExport     = var_export($this->pageStylesheetHrefs(), true);
        $whereTabsExport = var_export($this->whereTabsFor($tableName), true);

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

\PpPhp\Core\Auth\Permissions::requirePage('export', '$tableName');
\PpPhp\Core\DataOps\ExportPage::handle([
    'table'          => '$tableName',
    'columns'        => $colsExport,
    'allowed'        => $allowedExport,
    'caption'        => pp_table_caption('$tableName', '$tableLabel'),
    'lookups'        => $lookupExport,
    'optionMaps'     => $optionMapsExport,
    'includeHeaders' => $headersExport,
    'blocks'         => $blocksExport,
    'whereTabs'      => $whereTabsExport,
    'chrome'         => $chromeExport,
    'css'            => $cssExport,
]);
PHP;

        $path = $dir . '/export.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/export.php (DataOps bootstrap)");
        $this->filesWritten[] = $path;
        return $path;
    }

    /** print.php — clean printable list view (also serves as the PDF path via the browser). */
    private function writeTablePrintPage(string $dir, string $tableName, string $tableLabel, array $fields, string $fieldNamesStr): string
    {
        $lookupFields = $this->lookupFieldsFor($tableName);
        $optionMaps = $this->optionMapsFor($tableName);
        $fieldDefs = '';
        // Per-record definition list rows (single-record print), lookup-aware.
        $recDl = '';
        foreach ($fields as $f) {
            $fieldDefs .= "\$__cols['" . addslashes($f['name']) . "'] = '" . addslashes($f['label'] ?? $f['name']) . "';\n";
            if (!($f['view'] ?? true)) continue;
            $label = addslashes($f['label'] ?? $f['name']);
            $name = addslashes($f['name']);
            $type = (string) ($f['type'] ?? 'text');
            $full = in_array($type, ['textarea', 'html', 'richtext', 'json'], true) ? ' pp-dl-full' : '';
            if (isset($lookupFields[$f['name']]) && ($lookupFields[$f['name']]['displayAs'] ?? 'label') !== 'value') {
                $lkExport = var_export(['table' => $lookupFields[$f['name']]['table'], 'key' => $lookupFields[$f['name']]['key'], 'display' => $lookupFields[$f['name']]['display']], true);
                $disp = "<?= htmlspecialchars(pp_lookup_display_value($lkExport, (string) (\$row['$name'] ?? ''))) ?>";
            } elseif (isset($optionMaps[$f['name']])) {
                $omExport = var_export($optionMaps[$f['name']], true);
                $disp = "<?= htmlspecialchars(pp_option_display_value($omExport, (string) (\$row['$name'] ?? ''))) ?>";
            } elseif ($type === 'boolean') {
                $disp = "<?= (\$row['$name'] ?? '') ? 'Yes' : 'No' ?>";
            } elseif ($type === 'currency') {
                $disp = "<?= (\$row['$name'] ?? '') !== '' ? '\$' . number_format((float)(\$row['$name']), 2) : '' ?>";
            } elseif ($type === 'date' && $f['name'] !== 'id') {
                $disp = "<?= !empty(\$row['$name']) ? date('M j, Y', strtotime(\$row['$name'])) : '' ?>";
            } elseif ($type === 'datetime') {
                $disp = "<?= !empty(\$row['$name']) ? date('M j, Y g:i a', strtotime(\$row['$name'])) : '' ?>";
            } else {
                $disp = "<?= htmlspecialchars((string)(\$row['$name'] ?? '')) ?>";
            }
            $recDl .= "<div class=\"$full\"><dt>$label</dt><dd>$disp</dd></div>\n";
        }
        // Lookup fields print their display value, not the raw key (list mode).
        $lookupPhp = $lookupFields === [] ? '' : "\npp_apply_lookups(\$rows, " . var_export($lookupFields, true) . ');';
        if ($optionMaps !== []) $lookupPhp .= "\npp_apply_option_maps(\$rows, " . var_export($optionMaps, true) . ');';
        // Relations on the single-record printout (For Master Table → Print).
        $relPrint = $this->relationGridsHtml($tableName, 'print');
        $cssLinks = $this->pageStylesheetLinks();

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

\$table = '$tableName';
\PpPhp\Core\Auth\Permissions::requirePage('export', '$tableName');
\$validSorts = $fieldNamesStr;
\$id = (int) (\$_GET['id'] ?? 0);
\$__cols = [];
$fieldDefs
\$__caption = pp_table_caption('$tableName', '$tableLabel');
\$__brand = (string) (\$GLOBALS['PP_SHELL']['brand'] ?? '');
\$__brandImg = (string) (\$GLOBALS['PP_SHELL']['brandImage'] ?? '');
\$__single = \$id > 0;

if (\$__single) {
    \$__recs = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` WHERE `" . pp_pk(\$table) . "` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql(\$table), [\$id]);
    if (empty(\$__recs)) { http_response_code(404); exit('Record not found.'); }
    \$row = \PpPhp\Core\Auth\FieldCrypto::decryptRow(\$table, \$__recs[0]);
    \PpPhp\Core\Events::fire('tables.$tableName.page.beforeViewLoad', ['record' => &\$row]);
    \PpPhp\Core\Events::fire('tables.$tableName.page.processValues', ['record' => &\$row, 'action' => 'print']);
    \$__count = 1;
} else {
    \$where = '';
    \$params = [];
    \$search = trim((string) (\$_GET['search'] ?? ''));
    if (\$search !== '') {
        \$likes = [];
        foreach (\$validSorts as \$__f) { \$likes[] = "`\$__f` LIKE ?"; \$params[] = "%\$search%"; }
        \$where = 'WHERE (' . implode(' OR ', \$likes) . ')';
    }
    foreach ((array) (\$_GET['f'] ?? []) as \$__fc => \$__fv) {
        \$__fv = trim((string) \$__fv);
        if (\$__fv === '' || !in_array(\$__fc, \$validSorts, true)) continue;
        \$params[] = "%\$__fv%";
        \$where = (\$where === '' ? 'WHERE ' : \$where . ' AND ') . "`\$__fc` LIKE ?";
    }
    \$where = \PpPhp\Core\Auth\RowSecurity::mergeWhere(\$where, \$table);
    \$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` \$where ORDER BY `" . pp_pk(\$table) . "` ASC", \$params) ?: [];
    \$rows = \PpPhp\Core\Auth\FieldCrypto::decryptRows(\$table, \$rows);
    \PpPhp\Core\Events::fire('tables.$tableName.page.onPrint', ['records' => &\$rows, 'type' => 'print']);
    if (\PpPhp\Core\Events::has('tables.$tableName.page.processValues')) {
        foreach (\$rows as &\$__row) {
            \PpPhp\Core\Events::fire('tables.$tableName.page.processValues', ['record' => &\$__row, 'action' => 'print']);
        }
        unset(\$__row);
    }$lookupPhp
    \$__count = count(\$rows);
}
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= htmlspecialchars(\$__caption) ?><?= \$__single ? ' #' . \$id : '' ?></title>
$cssLinks
</head>
<body class="pp-print" style="margin:2rem;max-width:1000px;">
<div class="pp-print-header">
<?php if (\$__brandImg !== ''): ?><img class="pp-print-logo" src="<?= htmlspecialchars(str_starts_with(\$__brandImg, 'http') ? \$__brandImg : APP_BASE . \$__brandImg) ?>" alt="">
<?php elseif (\$__brand !== ''): ?><span class="pp-print-mark"><?= htmlspecialchars(strtoupper(substr(\$__brand, 0, 2))) ?></span><?php endif; ?>
<div>
<?php if (\$__brand !== ''): ?><div class="pp-print-org"><?= htmlspecialchars(\$__brand) ?></div><?php endif; ?>
<h1 class="pp-print-title"><?= htmlspecialchars(\$__caption) ?><?= \$__single ? ' · #' . \$id : '' ?></h1>
<div class="pp-print-meta"><?= \$__single ? '1 record' : (\$__count . ' record(s)') ?> · <?= date('M j, Y g:i a') ?></div>
</div>
<div class="pp-print-head-right pp-no-print"><button onclick="window.print()" class="ppd-grid-toolbar-btn"><i class="bi bi-printer"></i> Print</button></div>
</div>
<?php if (\$__single): ?>
<div class="pp-print-record">
<div class="pp-dl">
$recDl
</div>
$relPrint
</div>
<?php else: ?>
<table><thead><tr>
<?php foreach (\$__cols as \$__lbl): ?><th><?= htmlspecialchars(\$__lbl) ?></th><?php endforeach; ?>
</tr></thead><tbody>
<?php foreach (\$rows as \$__r): ?>
<tr><?php foreach (array_keys(\$__cols) as \$__c): ?><td><?= htmlspecialchars((string) (\$__r[\$__c] ?? '')) ?></td><?php endforeach; ?></tr>
<?php endforeach; ?>
</tbody></table>
<?php endif; ?>
<div class="pp-print-footer">
<span><?= htmlspecialchars(\$__brand !== '' ? \$__brand : \$__caption) ?></span>
<span><?= date('Y-m-d H:i') ?></span>
</div>
<script>window.PP_BASE='<?= APP_BASE ?>';(function(){try{if(new URLSearchParams(location.search).get('auto')!=='0')setTimeout(function(){window.print();},350);}catch(e){}})();</script>
</body></html>
PHP;

        $path = $dir . '/print.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/print.php");
        $this->filesWritten[] = $path;
        return $path;
    }

    /**
     * pdf.php — a professional, browser-previewed document that generates a real,
     * downloadable PDF client-side via the bundled html2pdf.js (no server library).
     * The layout follows the Pages module's PDF setting: 'print' (compact document)
     * or 'view' (record card); orientation feeds jsPDF. Handles a single record
     * (?id=N) and the filtered list.
     */
    private function writeTablePdfPage(string $dir, string $tableName, string $tableLabel, array $fields, string $fieldNamesStr): string
    {
        $pdf = $this->pdfSettingsFor($tableName);
        $pdfSource = $pdf['source'];       // 'print' | 'view'
        $pdfOrient = $pdf['orientation'];  // 'portrait' | 'landscape'
        $lookupFields = $this->lookupFieldsFor($tableName);
        $optionMaps = $this->optionMapsFor($tableName);

        $fieldDefs = '';
        $recRows = '';
        foreach ($fields as $f) {
            $fieldDefs .= "\$__cols['" . addslashes($f['name']) . "'] = '" . addslashes($f['label'] ?? $f['name']) . "';\n";
            if (!($f['view'] ?? true)) continue;
            $label = addslashes($f['label'] ?? $f['name']);
            $name = addslashes($f['name']);
            $type = (string) ($f['type'] ?? 'text');
            $full = in_array($type, ['textarea', 'html', 'richtext', 'json'], true) ? ' pp-pdf-full' : '';
            if (isset($lookupFields[$f['name']]) && ($lookupFields[$f['name']]['displayAs'] ?? 'label') !== 'value') {
                $lkExport = var_export(['table' => $lookupFields[$f['name']]['table'], 'key' => $lookupFields[$f['name']]['key'], 'display' => $lookupFields[$f['name']]['display']], true);
                $disp = "<?= htmlspecialchars(pp_lookup_display_value($lkExport, (string) (\$row['$name'] ?? ''))) ?>";
            } elseif (isset($optionMaps[$f['name']])) {
                $omExport = var_export($optionMaps[$f['name']], true);
                $disp = "<?= htmlspecialchars(pp_option_display_value($omExport, (string) (\$row['$name'] ?? ''))) ?>";
            } elseif ($type === 'boolean') {
                $disp = "<?= (\$row['$name'] ?? '') ? 'Yes' : 'No' ?>";
            } elseif ($type === 'currency') {
                $disp = "<?= (\$row['$name'] ?? '') !== '' ? '\$' . number_format((float)(\$row['$name']), 2) : '' ?>";
            } elseif ($type === 'date' && $f['name'] !== 'id') {
                $disp = "<?= !empty(\$row['$name']) ? date('M j, Y', strtotime(\$row['$name'])) : '' ?>";
            } elseif ($type === 'datetime') {
                $disp = "<?= !empty(\$row['$name']) ? date('M j, Y g:i a', strtotime(\$row['$name'])) : '' ?>";
            } else {
                $disp = "<?= htmlspecialchars((string)(\$row['$name'] ?? '')) ?>";
            }
            $recRows .= "<div class=\"pp-pdf-row$full\"><dt>$label</dt><dd>$disp</dd></div>\n";
        }
        $lookupPhp = $lookupFields === [] ? '' : "\npp_apply_lookups(\$rows, " . var_export($lookupFields, true) . ');';
        if ($optionMaps !== []) $lookupPhp .= "\npp_apply_option_maps(\$rows, " . var_export($optionMaps, true) . ');';
        $cssLinks = $this->pageStylesheetLinks();
        $sourceCls = $pdfSource === 'view' ? 'is-view' : 'is-print';

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

\$table = '$tableName';
\PpPhp\Core\Auth\Permissions::requirePage('export', '$tableName');
\$validSorts = $fieldNamesStr;
\$id = (int) (\$_GET['id'] ?? 0);
\$__cols = [];
$fieldDefs
\$__caption = pp_table_caption('$tableName', '$tableLabel');
\$__brand = (string) (\$GLOBALS['PP_SHELL']['brand'] ?? '');
\$__brandImg = (string) (\$GLOBALS['PP_SHELL']['brandImage'] ?? '');
\$__single = \$id > 0;

if (\$__single) {
    \$__recs = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` WHERE `" . pp_pk(\$table) . "` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql(\$table), [\$id]);
    if (empty(\$__recs)) { http_response_code(404); exit('Record not found.'); }
    \$row = \PpPhp\Core\Auth\FieldCrypto::decryptRow(\$table, \$__recs[0]);
    \PpPhp\Core\Events::fire('tables.$tableName.page.beforeViewLoad', ['record' => &\$row]);
    \PpPhp\Core\Events::fire('tables.$tableName.page.processValues', ['record' => &\$row, 'action' => 'pdf']);
} else {
    \$where = '';
    \$params = [];
    \$search = trim((string) (\$_GET['search'] ?? ''));
    if (\$search !== '') {
        \$likes = [];
        foreach (\$validSorts as \$__f) { \$likes[] = "`\$__f` LIKE ?"; \$params[] = "%\$search%"; }
        \$where = 'WHERE (' . implode(' OR ', \$likes) . ')';
    }
    foreach ((array) (\$_GET['f'] ?? []) as \$__fc => \$__fv) {
        \$__fv = trim((string) \$__fv);
        if (\$__fv === '' || !in_array(\$__fc, \$validSorts, true)) continue;
        \$params[] = "%\$__fv%";
        \$where = (\$where === '' ? 'WHERE ' : \$where . ' AND ') . "`\$__fc` LIKE ?";
    }
    \$where = \PpPhp\Core\Auth\RowSecurity::mergeWhere(\$where, \$table);
    \$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` \$where ORDER BY `" . pp_pk(\$table) . "` ASC", \$params) ?: [];
    \$rows = \PpPhp\Core\Auth\FieldCrypto::decryptRows(\$table, \$rows);$lookupPhp
    \$__count = count(\$rows);
}
\$__docName = preg_replace('/[^A-Za-z0-9_-]+/', '-', \$__caption . (\$__single ? '-' . \$id : '-list'));
// Embed mode (PPpdf.fromRecord) renders only the document; download=1 auto-fires.
\$__embed = !empty(\$_GET['embed']);
\$__auto  = !empty(\$_GET['download']);
\$__orient = in_array((string) (\$_GET['orientation'] ?? ''), ['portrait', 'landscape'], true) ? (string) \$_GET['orientation'] : '$pdfOrient';
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= htmlspecialchars(\$__caption) ?> · PDF<?= \$__single ? ' #' . \$id : '' ?></title>
$cssLinks
<style>
body.pp-pdf-body{margin:0;background:var(--pp-surface-soft,#f1f5f9);color:var(--pp-text,#0f172a);}
.pp-pdf-bar{position:sticky;top:0;z-index:10;display:flex;gap:.5rem;align-items:center;padding:.7rem 1rem;background:#111827;color:#fff;}
.pp-pdf-bar .pp-pdf-spacer{margin-left:auto;}
.pp-pdf-bar button,.pp-pdf-bar a{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem .8rem;border-radius:8px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);color:#fff;font-size:.85rem;font-weight:600;cursor:pointer;text-decoration:none;}
.pp-pdf-bar button.primary{background:var(--pp-primary,#6366f1);border-color:var(--pp-primary,#6366f1);color:var(--pp-primary-text,#fff);}
.pp-pdf-bar button:hover,.pp-pdf-bar a:hover{filter:brightness(1.08);}
.pp-pdf-bar button.is-on{background:var(--pp-primary,#6366f1);border-color:var(--pp-primary,#6366f1);}
/* Live options panel */
.pp-pdf-opts{position:sticky;top:52px;z-index:9;display:flex;flex-wrap:wrap;gap:1rem 1.4rem;align-items:flex-end;padding:.75rem 1rem;background:#1b2536;color:var(--pp-code-text,#e2e8f0);border-top:1px solid var(--pp-sidebar-border,rgba(255,255,255,.06));}
.pp-pdf-opts[hidden]{display:none;}
.pp-pdf-opt{display:flex;flex-direction:column;gap:.28rem;}
.pp-pdf-opt>span{font-size:.66rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--pp-muted,#94a3b8);}
.pp-pdf-opts select,.pp-pdf-opts input{background:var(--pp-text,#0f172a);color:var(--pp-primary-text,#fff);border:1px solid rgba(255,255,255,.16);border-radius:7px;padding:.35rem .5rem;font-size:.82rem;}
.pp-pdf-margins{display:flex;gap:.4rem;}
.pp-pdf-margins label{display:flex;flex-direction:column;align-items:center;gap:.15rem;font-size:.6rem;color:var(--pp-muted,#94a3b8);}
.pp-pdf-margins input{width:3.4rem;text-align:center;}
.pp-pdf-opts input[type=text]{width:15rem;max-width:40vw;}
.pp-pdf-seg{display:inline-flex;border:1px solid rgba(255,255,255,.16);border-radius:7px;overflow:hidden;}
.pp-pdf-seg button{border:0;border-radius:0;background:transparent;color:var(--pp-sidebar-text,#cbd5e1);padding:.35rem .7rem;font-size:.8rem;cursor:pointer;}
.pp-pdf-seg button.is-on{background:var(--pp-primary,#6366f1);color:var(--pp-primary-text,#fff);}
.pp-pdf-stage{padding:1.5rem 1rem;display:flex;justify-content:center;overflow-x:auto;}
#ppPdfDoc{background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);width:210mm;max-width:100%;box-sizing:border-box;padding:15mm;box-shadow:var(--pp-shadow-md,0 10px 40px rgba(2,6,23,.18));border-radius:6px;}
/* Custom header / footer (user-defined, hidden until set) */
.pp-pdf-custom-head,.pp-pdf-custom-foot{white-space:pre-wrap;overflow-wrap:anywhere;}
.pp-pdf-custom-head{margin:0 0 1rem;padding-bottom:.6rem;border-bottom:1px solid var(--pp-border,#e2e8f0);color:var(--pp-text,#334155);font-size:.9rem;font-weight:600;}
.pp-pdf-custom-foot{margin-top:1rem;padding-top:.6rem;border-top:1px solid var(--pp-border,#e2e8f0);color:var(--pp-muted,#64748b);font-size:.78rem;text-align:center;}
.pp-pdf-head{display:flex;align-items:center;gap:.9rem;border-bottom:2px solid var(--pp-border,#e2e8f0);padding-bottom:1rem;margin-bottom:1.2rem;}
.pp-pdf-logo{height:44px;width:auto;}
.pp-pdf-mark{display:inline-grid;place-items:center;width:44px;height:44px;border-radius:10px;background:var(--pp-primary,#6366f1);color:var(--pp-primary-text,#fff);font-weight:800;font-size:1.05rem;}
.pp-pdf-org{font-size:.78rem;letter-spacing:.03em;text-transform:uppercase;color:var(--pp-muted,#64748b);font-weight:700;}
.pp-pdf-title{font-size:1.35rem;font-weight:800;margin:.1rem 0 0;}
.pp-pdf-meta{margin-left:auto;text-align:right;font-size:.72rem;color:var(--pp-muted,#64748b);}
.pp-pdf-doc.is-print .pp-pdf-grid{display:grid;grid-template-columns:1fr;gap:0;}
.pp-pdf-doc.is-print .pp-pdf-row{display:grid;grid-template-columns:minmax(0,32%) minmax(0,68%);gap:1rem;padding:.5rem .2rem;border-bottom:1px solid #eef2f7;}
.pp-pdf-doc.is-view .pp-pdf-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.4rem 1.6rem;}
.pp-pdf-doc.is-view .pp-pdf-row{display:flex;flex-direction:column;gap:.15rem;padding:.5rem .2rem;border-bottom:1px solid #eef2f7;}
.pp-pdf-row.pp-pdf-full{grid-column:1/-1;}
.pp-pdf-row dt{margin:0;color:var(--pp-muted,#64748b);font-weight:700;font-size:.76rem;text-transform:uppercase;letter-spacing:.02em;}
.pp-pdf-row dd{margin:0;color:var(--pp-text,#0f172a);font-size:.92rem;overflow-wrap:anywhere;}
.pp-pdf-table{width:100%;border-collapse:collapse;font-size:.82rem;}
.pp-pdf-table th,.pp-pdf-table td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--pp-border,#e2e8f0);}
.pp-pdf-table th{background:var(--pp-surface-soft,#f8fafc);color:var(--pp-text,#475569);text-transform:uppercase;font-size:.68rem;letter-spacing:.04em;}
.pp-pdf-foot{margin-top:1.4rem;padding-top:.8rem;border-top:1px solid var(--pp-border,#e2e8f0);display:flex;justify-content:space-between;font-size:.72rem;color:var(--pp-muted,#94a3b8);}
</style>
</head>
<body class="pp-pdf-body<?= \$__embed ? ' pp-pdf-embed' : '' ?>" data-pp-pdf-name="<?= htmlspecialchars(\$__docName) ?>" data-pp-orientation="<?= htmlspecialchars(\$__orient) ?>">
<?php if (!\$__embed): ?>
<div class="pp-pdf-bar">
  <button type="button" class="primary" id="ppPdfDownload"><i class="bi bi-file-earmark-pdf"></i> Download PDF</button>
  <button type="button" id="ppPdfPrint"><i class="bi bi-printer"></i> Print</button>
  <button type="button" id="ppPdfOptsToggle" class="is-on"><i class="bi bi-sliders"></i> Options</button>
  <span class="pp-pdf-spacer"></span>
  <a href="javascript:history.back()"><i class="bi bi-arrow-left"></i> Back</a>
</div>
<div class="pp-pdf-opts" id="ppPdfOpts">
  <div class="pp-pdf-opt">
    <span>Orientation</span>
    <div class="pp-pdf-seg" id="ppOrient">
      <button type="button" data-orient="portrait"><i class="bi bi-file-earmark"></i> Portrait</button>
      <button type="button" data-orient="landscape"><i class="bi bi-file-earmark-break" style="transform:rotate(90deg)"></i> Landscape</button>
    </div>
  </div>
  <div class="pp-pdf-opt">
    <span>Margins (mm)</span>
    <div class="pp-pdf-margins">
      <label>Top<input type="number" id="mgT" min="0" max="60" step="1"></label>
      <label>Right<input type="number" id="mgR" min="0" max="60" step="1"></label>
      <label>Bottom<input type="number" id="mgB" min="0" max="60" step="1"></label>
      <label>Left<input type="number" id="mgL" min="0" max="60" step="1"></label>
    </div>
  </div>
  <div class="pp-pdf-opt">
    <span>Margin mode</span>
    <div class="pp-pdf-seg" id="ppMode">
      <button type="button" data-mode="fit" title="Fit the document onto the page (best for a single record)">Fit content</button>
      <button type="button" data-mode="page" title="Repeat the margins on every page (best for long, multi-record PDFs)">Every page</button>
    </div>
  </div>
  <div class="pp-pdf-opt">
    <span>Custom header</span>
    <input type="text" id="optHeader" placeholder="Optional header text…" maxlength="200">
  </div>
  <div class="pp-pdf-opt">
    <span>Custom footer</span>
    <input type="text" id="optFooter" placeholder="Optional footer text…" maxlength="200">
  </div>
  <div class="pp-pdf-opt">
    <span>&nbsp;</span>
    <button type="button" id="ppPdfReset" style="background:var(--pp-text,#0f172a);border:1px solid rgba(255,255,255,.16);color:var(--pp-sidebar-text,#cbd5e1);border-radius:7px;padding:.4rem .7rem;font-size:.8rem;cursor:pointer;"><i class="bi bi-arrow-counterclockwise"></i> Reset</button>
  </div>
</div>
<?php endif; ?>
<div class="pp-pdf-stage">
  <div id="ppPdfDoc" class="pp-pdf-doc $sourceCls">
    <div class="pp-pdf-custom-head" id="ppPdfCustomHead" hidden></div>
    <div class="pp-pdf-head">
      <?php if (\$__brandImg !== ''): ?><img class="pp-pdf-logo" src="<?= htmlspecialchars(str_starts_with(\$__brandImg, 'http') ? \$__brandImg : APP_BASE . \$__brandImg) ?>" alt="">
      <?php elseif (\$__brand !== ''): ?><span class="pp-pdf-mark"><?= htmlspecialchars(strtoupper(substr(\$__brand, 0, 2))) ?></span><?php endif; ?>
      <div>
        <?php if (\$__brand !== ''): ?><div class="pp-pdf-org"><?= htmlspecialchars(\$__brand) ?></div><?php endif; ?>
        <div class="pp-pdf-title"><?= htmlspecialchars(\$__caption) ?><?= \$__single ? ' · #' . \$id : '' ?></div>
      </div>
      <div class="pp-pdf-meta"><?= \$__single ? '1 record' : (\$__count . ' record(s)') ?><br><?= date('M j, Y g:i a') ?></div>
    </div>
    <?php if (\$__single): ?>
    <div class="pp-pdf-grid">
$recRows
    </div>
    <?php else: ?>
    <table class="pp-pdf-table"><thead><tr>
    <?php foreach (\$__cols as \$__lbl): ?><th><?= htmlspecialchars(\$__lbl) ?></th><?php endforeach; ?>
    </tr></thead><tbody>
    <?php foreach (\$rows as \$__r): ?>
    <tr><?php foreach (array_keys(\$__cols) as \$__c): ?><td><?= htmlspecialchars((string) (\$__r[\$__c] ?? '')) ?></td><?php endforeach; ?></tr>
    <?php endforeach; ?>
    </tbody></table>
    <?php endif; ?>
    <div class="pp-pdf-foot">
      <span><?= htmlspecialchars(\$__brand !== '' ? \$__brand : \$__caption) ?></span>
      <span><?= date('Y-m-d H:i') ?></span>
    </div>
    <div class="pp-pdf-custom-foot" id="ppPdfCustomFoot" hidden></div>
  </div>
</div>
<script>window.PP_BASE=window.PP_BASE||'<?= APP_BASE ?>';</script>
<script src="<?= APP_BASE ?>/assets/js/pp-pdf.js"></script>
<script>
(function(){
  var doc = document.getElementById('ppPdfDoc');
  var name = document.body.getAttribute('data-pp-pdf-name') || 'document';
  var isEmbed = document.body.classList.contains('pp-pdf-embed');
  var KEY = 'ppPdfOpts:$tableName';
  var defOrient = document.body.getAttribute('data-pp-orientation') || 'portrait';
  // Margins are shown on-screen as the document's own padding (mm). The margin
  // MODE decides how they translate to the PDF:
  //   fit  — WYSIWYG: one tall image, PDF margin 0 (the padding IS the margin).
  //   page — the padding is stripped at generation and html2pdf's native margins
  //          are applied, so the margins repeat on EVERY page (long multi-page docs).
  var state = { orient: defOrient, mgT: 15, mgR: 15, mgB: 15, mgL: 15, mmode: 'fit', header: '', footer: '' };
  if (!isEmbed) { try { var s = JSON.parse(localStorage.getItem(KEY) || 'null'); if (s && typeof s === 'object') Object.keys(state).forEach(function(k){ if (s[k] != null) state[k] = s[k]; }); } catch (e) {} }

  var chead = document.getElementById('ppPdfCustomHead');
  var cfoot = document.getElementById('ppPdfCustomFoot');
  function clampM(v){ v = parseInt(v, 10); if (isNaN(v)) v = 0; return Math.max(0, Math.min(60, v)); }
  function apply(){
    doc.style.width = (state.orient === 'landscape' ? '297mm' : '210mm');
    doc.style.padding = state.mgT + 'mm ' + state.mgR + 'mm ' + state.mgB + 'mm ' + state.mgL + 'mm';
    if (chead) { if (state.header) { chead.textContent = state.header; chead.hidden = false; } else { chead.hidden = true; } }
    if (cfoot) { if (state.footer) { cfoot.textContent = state.footer; cfoot.hidden = false; } else { cfoot.hidden = true; } }
    var seg = document.getElementById('ppOrient');
    if (seg) Array.prototype.forEach.call(seg.querySelectorAll('[data-orient]'), function(b){ b.classList.toggle('is-on', b.getAttribute('data-orient') === state.orient); });
    var modeSeg = document.getElementById('ppMode');
    if (modeSeg) Array.prototype.forEach.call(modeSeg.querySelectorAll('[data-mode]'), function(b){ b.classList.toggle('is-on', b.getAttribute('data-mode') === state.mmode); });
  }
  function save(){ if (isEmbed) return; try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} }

  var mgT = document.getElementById('mgT'), mgR = document.getElementById('mgR'), mgB = document.getElementById('mgB'), mgL = document.getElementById('mgL');
  var oh = document.getElementById('optHeader'), of = document.getElementById('optFooter');
  if (mgT) {
    mgT.value = state.mgT; mgR.value = state.mgR; mgB.value = state.mgB; mgL.value = state.mgL;
    oh.value = state.header; of.value = state.footer;
    document.getElementById('ppOrient').addEventListener('click', function(e){ var b = e.target.closest('[data-orient]'); if (!b) return; state.orient = b.getAttribute('data-orient'); apply(); save(); });
    document.getElementById('ppMode').addEventListener('click', function(e){ var b = e.target.closest('[data-mode]'); if (!b) return; state.mmode = b.getAttribute('data-mode'); apply(); save(); });
    [['mgT', mgT], ['mgR', mgR], ['mgB', mgB], ['mgL', mgL]].forEach(function(p){ p[1].addEventListener('input', function(){ state[p[0]] = clampM(p[1].value); apply(); save(); }); });
    oh.addEventListener('input', function(){ state.header = oh.value; apply(); save(); });
    of.addEventListener('input', function(){ state.footer = of.value; apply(); save(); });
    var tog = document.getElementById('ppPdfOptsToggle'), panel = document.getElementById('ppPdfOpts');
    if (tog && panel) tog.addEventListener('click', function(){ var hidden = panel.hasAttribute('hidden'); if (hidden) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', ''); tog.classList.toggle('is-on', hidden); });
    var rst = document.getElementById('ppPdfReset');
    if (rst) rst.addEventListener('click', function(){ state = { orient: defOrient, mgT: 15, mgR: 15, mgB: 15, mgL: 15, mmode: 'fit', header: '', footer: '' }; mgT.value = 15; mgR.value = 15; mgB.value = 15; mgL.value = 15; oh.value = ''; of.value = ''; apply(); save(); });
  }
  apply();

  function generate(){
    if (!window.PPpdf) { window.print(); return Promise.resolve(); }
    if (state.mmode === 'page') {
      // Native per-page margins: strip the on-screen padding, let html2pdf add
      // the margins (mm) to every page, then restore the padding afterwards.
      var savedPad = doc.style.padding;
      doc.style.padding = '0';
      var p = window.PPpdf.fromElement(doc, { orientation: state.orient, filename: name, margin: [state.mgT, state.mgL, state.mgB, state.mgR] });
      var restore = function(){ doc.style.padding = savedPad; };
      if (p && p.then) p.then(restore, restore); else restore();
      return p;
    }
    // Fit content (WYSIWYG): the padding IS the margin, PDF margin 0.
    return window.PPpdf.fromElement(doc, { orientation: state.orient, filename: name, margin: [0, 0, 0, 0] });
  }
  var dl = document.getElementById('ppPdfDownload');
  if (dl) dl.addEventListener('click', function(){ dl.disabled = true; var p = generate(); if (p && p.then) p.then(function(){ dl.disabled = false; }, function(){ dl.disabled = false; }); else dl.disabled = false; });
  var pr = document.getElementById('ppPdfPrint');
  if (pr) pr.addEventListener('click', function(){ window.print(); });
  // Auto-generate for PPpdf.fromRecord() (embed + download).
  if (<?= \$__auto ? 'true' : 'false' ?>) {
    (window.PPpdf ? window.PPpdf.ensureLib() : Promise.resolve()).then(function(){ setTimeout(generate, 250); });
  }
})();
</script>
</body></html>
PHP;

        $path = $dir . '/pdf.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/pdf.php");
        $this->filesWritten[] = $path;
        return $path;
    }

    /** The <link> stylesheet tags shared by generated standalone pages. */
    /** Stylesheet hrefs (relative to APP_BASE) shared by every generated page. */
    private function pageStylesheetHrefs(): array
    {
        return [
            'assets/bootstrap/css/bootstrap.min.css', 'assets/icons/bootstrap-icons.css',
            'assets/css/designer-base.css', 'assets/css/components.css',
            'assets/vendor/inter/inter.css', 'assets/css/theme.css', 'assets/css/shell.css', 'assets/css/app.css',
        ];
    }

    private function pageStylesheetLinks(): string
    {
        return implode('', array_map(
            static fn ($h) => '<link rel="stylesheet" href="<?= APP_BASE ?>/' . $h . '">',
            $this->pageStylesheetHrefs()
        ));
    }

    /**
     * CSS class(es) for an icon name using the platform conventions:
     * `fa-*` → FontAwesome, `bi-*` or bare names → Bootstrap Icons.
     * (Material ligature icons need a text node, so they are not usable in
     * a bare <i> here — the designer's pickers emit bi/fa for these slots.)
     */
    private function iconClass(string $name): string
    {
        $name = trim($name);
        if ($name === '') return '';
        if (str_starts_with($name, 'fa-') || str_contains($name, 'fa-solid') || str_contains($name, 'fa-regular')) {
            return str_contains($name, 'fa-solid') || str_contains($name, 'fa-regular') ? $name : 'fa-solid ' . $name;
        }
        return 'bi ' . (str_starts_with($name, 'bi-') ? $name : 'bi-' . $name);
    }

    /**
     * True when `$expr` is a single PHP expression and nothing more.
     *
     * Tokenising (rather than stripping every ';') keeps semicolons that live
     * INSIDE a string — a real need now that these expressions can carry SQL —
     * while still refusing anything that would smuggle extra statements or drop
     * out of PHP (e.g. closing the parenthesis, then a second statement that runs
     * an OS command, then a line comment to swallow the rest).
     */
    private function isSafeExpression(string $expr): bool
    {
        $tokens = @token_get_all('<?php (' . $expr . ');');
        if (!is_array($tokens) || $tokens === []) return false;
        $semis = 0;
        foreach ($tokens as $i => $t) {
            if ($t === ';') { $semis++; continue; }
            if (!is_array($t)) continue;
            // only the synthetic opener may be an open tag; no closing tag, no
            // inline HTML, and no second statement introduced by `echo` etc.
            if ($t[0] === T_OPEN_TAG || $t[0] === T_OPEN_TAG_WITH_ECHO) {
                if ($i !== 0) return false;
                continue;
            }
            if (in_array($t[0], [T_CLOSE_TAG, T_INLINE_HTML, T_HALT_COMPILER], true)) return false;
        }
        return $semis === 1;                       // just the terminator we added
    }

    /**
     * Compile a field's designed default value into a PHP expression for the
     * add page. "New"/'New' → string literal; a bare number → number; now() →
     * datetime, today() → date; anything with code syntax ($var, call(),
     * Class::method(), \Ns\Class::method(), ->, []) → evaluated PHP; plain text
     * → literal. Validated with php -l at build time so a typo can't break the
     * page (falls back to literal). Returns a PHP expression, or null if none.
     */
    private function compileDefaultExpr(string $default): ?string
    {
        $default = trim($default);
        if ($default === '') {
            return null;
        }
        // Quoted → already a valid PHP string literal
        if (preg_match('/^"(?:[^"\\\\]|\\\\.)*"$/s', $default) || preg_match("/^'(?:[^'\\\\]|\\\\.)*'$/s", $default)) {
            return $default;
        }
        // A bare number stays a number (not a quoted string).
        if (preg_match('/^-?\d+(?:\.\d+)?$/', $default)) {
            return $default;
        }
        // Anything carrying code syntax — $var, call(), Class::method(),
        // \Ns\Class::method(), ->, [] — is compiled as an EXPRESSION. Bare words
        // and sentences stay literal text, so a default of "Pending" can never
        // turn into an undefined constant.
        if (preg_match('/[$(\[]|::|->/', $default)) {
            $expr = trim((string) preg_replace(['/\bnow\(\)/', '/\btoday\(\)/'],
                ["date('Y-m-d H:i:s')", "date('Y-m-d')"], $default));
            if ($this->isSafeExpression($expr)) {
                // Validate the generated expression parses as PHP — WITHOUT spawning a
                // shell (php -l) or using eval. token_get_all() with TOKEN_PARSE fully
                // parses the snippet and throws on a syntax error, so it is an
                // in-process lint. This also keeps the generator free of OS-command
                // calls, which host malware scanners heuristically flag on a file that
                // writes PHP.
                try {
                    token_get_all("<?php \$__v = ($expr);", TOKEN_PARSE);
                    return $expr;
                } catch (\Throwable $e) {
                    // not valid PHP → fall through to the safe literal below
                }
            }
        }
        // Plain text → literal
        return var_export($default, true);
    }

    /**
     * Assemble a form field's outer markup honouring the label style
     * (default / inline / float-outline / float-filled) + optional pre/post
     * input-group icons + label width/gap. One place so canvas + build match.
     */
    private function fieldShell(string $spanStyle, string $forId, string $label, string $reqMark, string $inputHtml, string $helpHtml, string $labelStyle, string $iconPre = '', string $iconPost = '', bool $canFloat = true, string $labelWidth = '', string $labelGap = '', string $fieldName = ''): string
    {
        // A stable hook so the client Runner/pp.ctrl API can find ANY field's
        // container by DB name, whatever control (plain input or plugin) it holds.
        $dataField = $fieldName !== '' ? ' data-pp-field="' . htmlspecialchars($fieldName) . '"' : '';
        $pre = $iconPre !== '' ? '<span class="pp-fld-ic pre"><i class="' . htmlspecialchars($this->iconClass($iconPre)) . '"></i></span>' : '';
        $post = $iconPost !== '' ? '<span class="pp-fld-ic post"><i class="' . htmlspecialchars($this->iconClass($iconPost)) . '"></i></span>' : '';
        $wrapCls = 'pp-fld-wrap' . ($pre !== '' ? ' has-pre' : '') . ($post !== '' ? ' has-post' : '');
        $wrapInner = $pre . $inputHtml . $post;
        $lblAttr = $forId !== '' ? " for=\"$forId\"" : '';
        $labelTag = "<label$lblAttr>$label$reqMark</label>";

        // Merge label width / gap CSS vars into the field's style attribute.
        $vars = '';
        $lw = trim($labelWidth);
        $lg = trim($labelGap);
        if ($lw !== '') $vars .= '--lbl-w:' . htmlspecialchars($this->sanitizeCssValue($lw)) . ';';
        if ($lg !== '') $vars .= '--lbl-gap:' . htmlspecialchars($this->sanitizeCssValue($lg)) . ';';
        $style = $spanStyle;
        if ($vars !== '') {
            $style = str_contains($spanStyle, 'style="')
                ? str_replace('style="', 'style="' . $vars, $spanStyle)
                : " style=\"$vars\"";
        }

        $isFloat = str_starts_with($labelStyle, 'float') && $canFloat;
        if ($isFloat) {
            $cls = 'pp-fld is-float ' . ($labelStyle === 'float-filled' ? 'is-float-filled' : 'is-float-outline');
            // label AFTER input so the CSS sibling selector can float it
            return "<div class=\"$cls\"$style$dataField><div class=\"$wrapCls\">$wrapInner$labelTag</div>$helpHtml</div>\n";
        }
        $cls = 'pp-fld' . ($labelStyle === 'inline' ? ' is-inline' : '');
        return "<div class=\"$cls\"$style$dataField>$labelTag<div class=\"$wrapCls\">$wrapInner</div>$helpHtml</div>\n";
    }

    /** Strip characters that could break out of a CSS declaration. */
    private function sanitizeCssValue(string $v): string
    {
        return trim(str_replace(['{', '}', ';', '"', '<', '>'], '', $v));
    }

    /** Responsive width span → `style="--sp:..;--spt:..;--spm:.."` (component in a 12-col grid). */
    private function spanStyleAttr(array $props): string
    {
        $spans = is_array($props['spans'] ?? null) ? $props['spans'] : [];
        $clamp = static fn ($v, int $d): int => (is_numeric($v) && (int) $v >= 1 && (int) $v <= 12) ? (int) $v : $d;
        $d = $clamp($spans['desktop'] ?? 12, 12);
        $t = $clamp($spans['tablet'] ?? null, $d);
        $m = $clamp($spans['mobile'] ?? null, 12);
        return " style=\"--sp:$d;--spt:$t;--spm:$m\"";
    }

    /**
     * Translate a node's `style` bag (set by the Designer's Style panel) into an
     * inline CSS string. Mirrors the canvas renderer's mapping EXACTLY so a
     * component looks identical in the Designer and the generated app.
     */
    private function nodeStyleCss(array $style): string
    {
        static $map = [
            'color' => 'color', 'background' => 'background', 'font' => 'font-family',
            'fontSize' => 'font-size', 'fontWeight' => 'font-weight', 'align' => 'text-align',
            'padding' => 'padding', 'margin' => 'margin', 'width' => 'width',
            'minHeight' => 'min-height', 'opacity' => 'opacity',
            'border' => 'border', 'borderRadius' => 'border-radius', 'shadow' => 'box-shadow',
        ];
        $css = '';
        foreach ($map as $k => $prop) {
            $v = $this->sanitizeCssValue((string) ($style[$k] ?? ''));
            if ($v !== '') $css .= $prop . ':' . $v . ';';
        }
        return $css;
    }

    /**
     * Wrap ANY presentational component so it honours its responsive width span
     * (6/12, 4/12…) AND its Style-panel appearance (background, border, margin,
     * shadow, alignment…). Wraps only when there is something to apply, so a
     * plain full-width component stays unwrapped. The wrapper is the grid child,
     * carrying --sp/--spt/--spm — identical to how the canvas wraps each node.
     */
    private function wrapComponent(array $c, string $html): string
    {
        $props = (array) ($c['props'] ?? []);
        $style = (array) ($c['style'] ?? []);
        $spans = is_array($props['spans'] ?? null) ? $props['spans'] : [];
        $d = (int) ($spans['desktop'] ?? 12);
        $hasSpan = ($d >= 1 && $d < 12);
        // Per-device visibility ("Show on" in the inspector) — hidden via CSS media
        // queries that match the per-device span breakpoints (mobile < 768, tablet
        // 768–1023, desktop ≥ 1024). Lets one responsive page show a full data grid
        // on desktop and a compact card list on mobile, no separate URL needed.
        $hideOn = array_values(array_filter(
            (array) ($props['hideOn'] ?? []),
            static fn ($x) => in_array($x, ['desktop', 'tablet', 'mobile'], true)
        ));
        $hideCls = '';
        foreach ($hideOn as $dev) {
            $hideCls .= ' pp-hide-' . $dev;
        }
        $styleCss = $this->nodeStyleCss($style);
        $extraClass = trim((string) ($style['class'] ?? ''));
        $customCss = trim((string) ($style['customCss'] ?? ''));
        // Scroll-reveal ("Animate in"): arm + reveal on scroll into view via a tiny
        // idempotent script. Falls back to fully visible with no JS / reduced motion.
        $revealCls = '';
        if (in_array((string) ($style['reveal'] ?? ''), ['fade', 'up', 'down', 'left', 'right', 'zoom'], true)) {
            $revealCls = ' ppd-c-reveal rv-' . (string) $style['reveal'];
            if (in_array((string) ($style['revealDelay'] ?? ''), ['1', '2', '3', '4'], true)) $revealCls .= ' rd-' . (string) $style['revealDelay'];
        }
        if (!$hasSpan && $styleCss === '' && $extraClass === '' && $customCss === '' && $hideCls === '' && $revealCls === '') {
            return $html;
        }
        $inline = '';
        if ($hasSpan) {
            // Menu cards are floored at span 2 → at most 6 per row.
            $floor = ((string) ($c['type'] ?? '') === 'menu-card') ? 2 : 1;
            $clamp = static fn ($v, int $def) => (is_numeric($v) && (int) $v >= 1 && (int) $v <= 12) ? (int) $v : $def;
            $dd = max($floor, $clamp($spans['desktop'] ?? 12, 12));
            $tt = max($floor, $clamp($spans['tablet'] ?? null, $dd));
            $mm = max($floor, $clamp($spans['mobile'] ?? null, 12));
            $inline .= "--sp:$dd;--spt:$tt;--spm:$mm;";
        }
        $inline .= $styleCss;
        // Per-component scoped Custom CSS → a stable class shared with the canvas.
        $scopeCls = '';
        $styleBlock = '';
        if ($customCss !== '') {
            $scope = 'ppcss-' . preg_replace('/[^a-zA-Z0-9_-]/', '-', (string) ($c['id'] ?? ''));
            $scopeCls = ' ' . $scope;
            $styleBlock = $this->customCssBlock('.' . $scope, $customCss);
        }
        $cls = 'pp-grid-item' . ($extraClass !== '' ? ' ' . htmlspecialchars($extraClass) : '') . $hideCls . $scopeCls . $revealCls;
        $styleAttr = $inline !== '' ? ' style="' . htmlspecialchars($inline) . '"' : '';
        return $styleBlock . '<div class="' . $cls . '"' . $styleAttr . '>' . $html . '</div>'
            . ($revealCls !== '' ? \PpPhp\Core\Build\ComponentRenderer::revealScript() : '');
    }

    /**
     * Build a scoped <style> block from a component's Custom CSS. The CSS is
     * scoped to the component's unique class so it can't leak. Convention (same
     * as the canvas): use `&` to mean "this element" (enables :hover, children…);
     * otherwise the text is treated as declarations applied to the element.
     */
    private function customCssBlock(string $scope, string $css): string
    {
        $css = str_replace('<', '', $css); // prevent </style> breakout
        // Pull out any "& … { … }" rules (pseudo-states / children); the rest is
        // treated as declarations applied to the element itself.
        $rules = '';
        $base = preg_replace_callback('/&[^{]*\{[^}]*\}/', function (array $m) use (&$rules, $scope): string {
            $rules .= str_replace('&', $scope, $m[0]);
            return '';
        }, $css);
        $base = trim((string) $base);
        $out = ($base !== '' ? $scope . '{' . $base . '}' : '') . $rules;
        return $out !== '' ? '<style>' . $out . '</style>' : '';
    }

    /** Back-compat alias — spans only. Prefer wrapComponent (spans + style). */
    private function spanWrap(array $c, string $html): string
    {
        return $this->wrapComponent($c, $html);
    }

    /** Section header background / text / font → inline style (or ''). */
    private function headerStyleAttr(array $props): string
    {
        $s = '';
        $bg = $this->sanitizeCssValue((string) ($props['headerBg'] ?? ''));
        $fg = $this->sanitizeCssValue((string) ($props['headerColor'] ?? ''));
        $font = $this->sanitizeCssValue((string) ($props['headerFont'] ?? ''));
        if ($bg !== '') $s .= 'background:' . $bg . ';';
        if ($fg !== '') $s .= 'color:' . $fg . ';';
        if ($font !== '') $s .= "font-family:'" . $font . "',inherit;";
        return $s !== '' ? ' style="' . htmlspecialchars($s) . '"' : '';
    }

    /**
     * <link>/<script> tags for the assets() of the edit controls used on a page,
     * driven entirely by each plugin's declared assets() — so a new plugin wires
     * itself into generated pages with no build-code changes. Flatpickr is loaded
     * unconditionally (list filters need it) so its paths are skipped here.
     *
     * @param array<int,string> $controls
     * @return array{0:string,1:string} [cssTags, jsTags]
     */
    private function pluginHeadAssets(array $controls): array
    {
        // Search every field-control category so a page including a View control
        // (e.g. the Image gallery / File preview → pp-lightbox) gets its assets
        // just like an Edit control does. Ids are looked up across all registries.
        static $catalogue = null;
        if ($catalogue === null) {
            $catalogue = \PpPhp\Core\PluginRegistry::edit()
                + \PpPhp\Core\PluginRegistry::view()
                + \PpPhp\Core\PluginRegistry::filter();
        }
        $css = [];
        $js  = [];
        foreach ($controls as $ctrl) {
            $a = $catalogue[$ctrl]['assets'] ?? null;
            if (!is_array($a)) continue;
            foreach ((array) ($a['css'] ?? []) as $p) {
                $p = ltrim((string) $p, '/');
                if ($p !== '' && !str_contains($p, '/flatpickr/')) $css[$p] = true;
            }
            foreach ((array) ($a['js'] ?? []) as $p) {
                $p = ltrim((string) $p, '/');
                if ($p !== '' && !str_contains($p, '/flatpickr/')) $js[$p] = true;
            }
        }
        $cssTags = '';
        foreach (array_keys($css) as $p) {
            $cssTags .= '<link rel="stylesheet" href="<?= APP_BASE ?>/' . htmlspecialchars($p) . '">';
        }
        $jsTags = '';
        foreach (array_keys($js) as $p) {
            $jsTags .= "\n<script src=\"<?= APP_BASE ?>/" . htmlspecialchars($p) . "\"></script>";
        }
        return [$cssTags, $jsTags];
    }

    private function emitFormField(array $f, string $tableName, string $mode, int $defaultSpan): string
    {
        $name = addslashes($f['name']);
        $rawLabel = (string) ($f['label'] ?? $f['name']);
        // Label is resolved at runtime from the Misc Label Editor (per-language),
        // falling back to the designed label — same pattern as placeholder/tooltip.
        $label = "<?= htmlspecialchars(pp_t('label.$tableName.$name', '" . addslashes($rawLabel) . "')) ?>";
        $requiredAttr = ($f['required'] ?? false) ? ' required' : '';
        $reqMark = ($f['required'] ?? false) ? '<span class="pp-req">*</span>' : '';
        $placeholder = addslashes((string) ($f['placeholder'] ?? ''));
        $phAttr = " placeholder=\"<?= htmlspecialchars(pp_t('placeholder.$tableName.$name', '$placeholder')) ?>\"";
        $tooltipAttr = " title=\"<?= htmlspecialchars(pp_t('tooltip.$tableName.$name', '')) ?>\"";
        $helpHtml = trim((string) ($f['help'] ?? '')) !== ''
            ? '<small class="pp-field-help">' . htmlspecialchars((string) $f['help']) . '</small>' : '';

        $span = (int) ($f['span'] ?? $defaultSpan);
        if ($span < 1 || $span > 12) $span = $defaultSpan;
        $spanT = (int) ($f['spanTablet'] ?? $span);
        $spanM = (int) ($f['spanMobile'] ?? 12);
        // Style panel → Min height. On a textarea it grows the CONTROL itself
        // (that's what balances a tall notes column); on any other control it
        // grows the .pp-fld wrapper. Bare numbers mean px.
        $minH = trim((string) ($f['minHeight'] ?? ''));
        if ($minH !== '' && preg_match('/^\d+(\.\d+)?$/', $minH)) $minH .= 'px';
        $minH = $minH !== '' ? $this->sanitizeCssValue($minH) : '';
        $fldType0 = (string) ($f['designType'] ?? $f['type'] ?? 'text');
        $wrapMinH = ($minH !== '' && $fldType0 !== 'textarea') ? ";min-height:$minH" : '';
        $taMinH = ($minH !== '' && $fldType0 === 'textarea') ? " style=\"min-height:$minH\"" : '';
        $spanStyle = " style=\"--sp:$span;--spt:$spanT;--spm:$spanM$wrapMinH\"";
        $labelWidth = (string) ($f['labelWidth'] ?? '');
        $labelGap = (string) ($f['labelGap'] ?? '');

        // Value expression. Add mode honours the designed default value, which
        // may be a PHP expression (now(), date('Y'), "New"…), compiled + lint-
        // validated at build time.
        $defaultExpr = ($mode === 'add') ? $this->compileDefaultExpr((string) ($f['defaultValue'] ?? '')) : null;
        $valueExpr = $defaultExpr !== null
            ? "\$row['$name'] ?? ($defaultExpr)"
            : "\$row['$name'] ?? ''";

        $type = (string) ($f['designType'] ?? $f['type'] ?? 'text');
        $labelStyle = (string) ($f['labelStyle'] ?? 'default');
        $iconPreName = trim((string) ($f['iconPre'] ?? $f['icon'] ?? ''));
        $iconPostName = trim((string) ($f['iconPost'] ?? ''));

        // Resolve the effective edit control: Designer per-page component control,
        // then a PER-PAGE field override for this page ($mode = add|edit), then the
        // field-level editAs default. ("Assign once" + optional per-page overrides.)
        $control = (string) ($f['editControl'] ?? '');
        $settings = (array) ($f['editSettings'] ?? []);
        if ($control === '' || $control === 'TextBox') {
            $pp = $this->perPageControl($f, $mode);
            if ($pp !== null) {
                $control = $pp['control'];
                $settings = $pp['settings'];
            } else {
                $legacy = (string) ($f['editAs'] ?? '');
                if ($legacy !== '' && $legacy !== 'TextBox') {
                    $control = $legacy;
                    $settings = (array) ($f['editAsSettings'] ?? []);
                }
            }
        }
        $isPlugin = ($control !== '' && $control !== 'TextBox');

        // File/Image default: when no sub-folder is set, store this table's uploads
        // under a folder named after the table (Files/<table>/…). The user can
        // override the Sub-folder setting; a set value always wins.
        if ($control === 'FileImage' && trim((string) ($settings['subfolder'] ?? $settings['uploadFolder'] ?? '')) === '') {
            $settings['subfolder'] = $tableName;
        }

        // Float labels only work on plain text-like inputs (need :placeholder-shown).
        $floatable = !$isPlugin && in_array($type, ['text', 'email', 'password', 'tel', 'url', 'number', 'currency', 'textarea'], true);
        $floating = str_starts_with($labelStyle, 'float') && $floatable;
        if ($floating) {
            // A single-space placeholder makes the CSS :placeholder-shown trick work.
            $phAttr = ' placeholder=" "';
        }

        // Auto-increment key: read-only, never submitted. It still honours the
        // designed label style — the placeholder=" " keeps the float CSS's
        // :placeholder-shown trick working, and because the box always carries a
        // value ("auto" or the real id) the label stays in its floated position.
        // Primary-key lock. The DB owns an AUTO_INCREMENT key, so it is never
        // user-editable (shows "auto" on Add, the real value on Edit). A MANUAL
        // primary key (no AI — e.g. a `code`) stays editable on Add so the user
        // can supply it, but is locked on Edit (changing a set key is unsafe).
        // Keyed on the table's REAL pk name (id, std_id, customer_id, …), never a
        // hardcoded 'id'. See detectPrimaryKey()/primaryKeyIsAutoFor().
        $pkName = $this->primaryKeyFor($tableName);
        $pkAuto = $this->primaryKeyIsAutoFor($tableName);
        if ((string) $f['name'] === $pkName && ($pkAuto || $mode === 'edit')) {
            $autoPh = $pkAuto ? 'auto' : '';
            $input = "<input type=\"text\" value=\"<?= htmlspecialchars(\$row['$pkName'] ?? '$autoPh') ?>\" disabled placeholder=\" \" class=\"pp-input\">";
            return $this->fieldShell($spanStyle, '', $label, '', $input, $helpHtml, $labelStyle, '', '', true, $labelWidth, $labelGap, (string) $f['name']);
        }

        if ($isPlugin) {
            // Date/time plugins get an auto calendar/clock lead icon if none set.
            if ($iconPreName === '' && in_array($control, ['DatePicker', 'TimePicker', 'NepaliDatePicker'], true)) {
                $iconPreName = $control === 'TimePicker' ? 'bi-clock'
                    : ($control === 'NepaliDatePicker' ? 'bi-calendar-event' : 'bi-calendar3');
            }
            $settingsExport = var_export($settings, true);
            if ($control === 'LookupWizard') {
                // Runtime extras: the registry id (AJAX suggestion endpoint) and,
                // for cascades, the parent field's CURRENT row value so the Edit
                // form pre-filters the options server-side.
                $runtimeExtra = "'_lookupId' => '" . addslashes($this->currentFormTable . '.' . $f['name']) . "'";
                $parentF = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($settings['parentField'] ?? ''));
                if ($parentF !== '' && trim((string) ($settings['filterField'] ?? '')) !== '') {
                    $runtimeExtra .= ", '_parentValue' => (string) (\$row['$parentF'] ?? '')";
                }
                $settingsExport = "array_merge($settingsExport, [$runtimeExtra])";
            }
            $input = "<?= \\PpPhp\\Core\\Designer\\FieldControl\\FieldControlRuntime::render('edit', '" . addslashes($control) . "', '$name', $valueExpr, $settingsExport)['html'] ?>";
            return $this->fieldShell($spanStyle, "field_$name", $label, $reqMark, $input, $helpHtml, $labelStyle, $iconPreName, $iconPostName, false, $labelWidth, $labelGap, (string) $f['name']);
        }

        // Typed HTML inputs
        $valueAttr = "value=\"<?= htmlspecialchars($valueExpr) ?>\"";
        $canFloat = $floatable;
        if ($type === 'textarea') {
            $input = "<textarea name=\"$name\" id=\"field_$name\" class=\"pp-input\"$taMinH$requiredAttr$phAttr$tooltipAttr><?= htmlspecialchars($valueExpr) ?></textarea>";
        } elseif ($type === 'select') {
            $optHtml = '';
            foreach ((array) ($f['options'] ?? []) as $opt) {
                $optSafe = addslashes((string) $opt);
                $optEsc = htmlspecialchars((string) $opt);
                $optHtml .= "<option value=\"$optEsc\"<?= ($valueExpr) === '$optSafe' ? ' selected' : '' ?>>$optEsc</option>";
            }
            $input = "<select name=\"$name\" id=\"field_$name\" class=\"pp-input\"$requiredAttr$tooltipAttr><option value=\"\">Choose…</option>$optHtml</select>";
            $canFloat = false;
        } elseif ($type === 'boolean' || $type === 'checkbox' || $type === 'toggle') {
            $checked = "<?= ($valueExpr) == '1' ? ' checked' : '' ?>";
            return "<div class=\"pp-fld\"$spanStyle><label class=\"pp-checkline\"><input type=\"hidden\" name=\"$name\" value=\"0\">"
                . "<input type=\"checkbox\" name=\"$name\" id=\"field_$name\" value=\"1\"$checked$tooltipAttr><span>$label$reqMark</span></label>$helpHtml</div>\n";
        } elseif (in_array($type, ['date', 'datetime', 'datetime-local', 'time'], true)) {
            // Text input enhanced by the bundled flatpickr (float disabled — the
            // altInput swap breaks :placeholder-shown; use a lead calendar icon).
            $kind = $type === 'date' ? 'date' : ($type === 'time' ? 'time' : 'datetime');
            $input = "<input type=\"text\" name=\"$name\" id=\"field_$name\" $valueAttr class=\"pp-input\" data-pp-picker=\"$kind\"$requiredAttr$phAttr$tooltipAttr autocomplete=\"off\">";
            if ($iconPreName === '') {
                $iconPreName = $kind === 'time' ? 'bi-clock' : 'bi-calendar3';
            }
            $canFloat = false;
        } elseif ($type === 'number' || $type === 'currency') {
            $step = $type === 'currency' ? '0.01' : 'any';
            $input = "<input type=\"number\" step=\"$step\" name=\"$name\" id=\"field_$name\" $valueAttr class=\"pp-input\"$requiredAttr$phAttr$tooltipAttr>";
        } elseif (in_array($type, ['email', 'password', 'tel', 'url', 'color'], true)) {
            $val = $type === 'password' ? 'value=""' : $valueAttr;
            $input = "<input type=\"$type\" name=\"$name\" id=\"field_$name\" $val class=\"pp-input\"$requiredAttr$phAttr$tooltipAttr>";
            if ($type === 'color') $canFloat = false;
        } else {
            $input = "<input type=\"text\" name=\"$name\" id=\"field_$name\" $valueAttr class=\"pp-input\"$requiredAttr$phAttr$tooltipAttr>";
        }

        return $this->fieldShell($spanStyle, "field_$name", $label, $reqMark, $input, $helpHtml, $labelStyle, $iconPreName, $iconPostName, $canFloat, $labelWidth, $labelGap, (string) $f['name']);
    }

    /**
     * A section heading that matches a known auto-scaffolded default is UI chrome →
     * emit a runtime pp_t() tag so it follows the app language; any author-typed
     * heading is escaped literally. (Mirror of ComponentRenderer::chromeHeading.)
     */
    private function chromeHeadingHtml(string $text): string
    {
        $map = [
            'New Record'     => ['ui.new_record', 'New Record'],
            'Edit Record'    => ['ui.edit_record', 'Edit Record'],
            'Record Details' => ['ui.record_details', 'Record Details'],
        ];
        if (isset($map[$text])) {
            [$k, $d] = $map[$text];
            return "<?= htmlspecialchars(pp_t('" . $k . "', '" . $d . "')) ?>";
        }
        return htmlspecialchars($text);
    }

    /** Designed form buttons → generated action bar (submit/link semantics). */
    private function renderFormButtons(array $props, string $tableName, string $mode, bool $sticky, bool $requiredHint): string
    {
        $defs = [
            'save'       => ['submit', 'bi-check-lg', 'Save', 'primary', '', 'save'],
            'save_new'   => ['submit', 'bi-plus-circle', 'Save & New', '', 'new', 'save_new'],
            'save_close' => ['submit', 'bi-check2-all', 'Save & Close', '', '', 'save_close'],
            'cancel'     => ['link', 'bi-x-lg', 'Cancel', '', '', 'cancel'],
            'back'       => ['link', 'bi-arrow-left', 'Back', '', '', 'back'],
            'home'       => ['home', 'bi-house', 'Back to Home', '', '', 'back_home'],
            'reset'      => ['reset', 'bi-arrow-counterclockwise', 'Reset', '', '', 'reset'],
        ];
        $config = (array) ($props['buttonConfig'] ?? []);
        $keys = (array) ($props['buttons'] ?? []);
        if ($keys === []) $keys = $mode === 'add' ? ['save', 'save_new', 'cancel'] : ['save', 'cancel'];

        $out = '';
        foreach ($keys as $key) {
            $key = (string) $key;
            if (!isset($defs[$key])) continue;
            [$kind, $icon, $labelDefault, $variantDefault, $after, $tkey] = $defs[$key];
            $cfg = (array) ($config[$key] ?? []);
            if (($cfg['visible'] ?? true) === false) continue;
            // Default label → translatable at runtime; a custom label stays literal.
            $custom = (string) ($cfg['label'] ?? '');
            $label = $custom !== ''
                ? htmlspecialchars($custom)
                : "<?= htmlspecialchars(pp_t('ui." . $tkey . "', '" . $labelDefault . "')) ?>";
            $iconCls = htmlspecialchars($this->iconClass((string) ($cfg['icon'] ?? $icon)));
            $variant = (string) ($cfg['variant'] ?? $variantDefault);
            $cls = 'pp-btn-form' . ($variant === 'primary' ? ' primary' : ($variant === 'danger' ? ' danger' : ''));
            $display = (string) ($cfg['displayMode'] ?? 'icon_label');
            $inner = ($display === 'label' ? '' : "<i class=\"$iconCls\"></i>") . ($display === 'icon' ? '' : "<span>$label</span>");
            $titleAttr = $display === 'icon' ? " title=\"$label\"" : '';
            $btnAttr = " data-pp-btn=\"$key\"";   // Runner hook: Runner.button('save').hide()

            if ($kind === 'submit') {
                $afterAttr = $after !== '' ? " name=\"__after\" value=\"$after\"" : '';
                $out .= "<button type=\"submit\" class=\"$cls\"$afterAttr$titleAttr$btnAttr>$inner</button>\n";
            } elseif ($kind === 'reset') {
                $out .= "<button type=\"reset\" class=\"$cls\"$titleAttr$btnAttr>$inner</button>\n";
            } elseif ($kind === 'home') {
                $out .= "<a href=\"<?= APP_BASE ?>/\" class=\"$cls\"$titleAttr$btnAttr>$inner</a>\n";
            } else {
                $out .= "<a href=\"<?= APP_BASE ?>/generated/$tableName/list.php\" class=\"$cls\"$titleAttr$btnAttr>$inner</a>\n";
            }
        }
        $hint = $requiredHint
            ? '<span class="pp-required-hint"><span class="pp-req">*</span> required field</span>'
            : '';

        return '<div class="pp-form-actions' . ($sticky ? ' pp-sticky' : '') . '">' . $out . $hint . '</div>';
    }

    /**
     * Walk the designed form page and emit its full body in order: section
     * cards (icon + heading), form grids, fields, headings, text, dividers
     * and the designed action buttons. Returns null when the design carries
     * no field components (caller falls back to the flat Fields-module form).
     */
    private function renderDesignedFormBody(array $design, string $tableName, array $fieldsByName, string $mode, array $formProps): ?string
    {
        if ($this->designFormFields($design) === []) {
            return null;
        }
        $defaultSpanProp = $formProps['fieldSpan'] ?? 12;
        $defaultSpan = (int) (is_array($defaultSpanProp) ? ($defaultSpanProp['desktop'] ?? 12) : $defaultSpanProp);
        if ($defaultSpan < 1 || $defaultSpan > 12) $defaultSpan = 12;
        $sticky = !empty($formProps['stickyActions']);
        $requiredHint = ($formProps['requiredHint'] ?? true) !== false;
        $buttonsRendered = false;

        $emitField = function (array $c) use ($tableName, $fieldsByName, $mode, $defaultSpan): string {
            $df = $this->designFieldConfig($c);
            if ($df['field'] === '') return '';
            $base = $fieldsByName[$df['field']] ?? ['name' => $df['field'], 'type' => 'text'];
            $f = array_merge($base, $df, [
                'name'       => $df['field'],
                'label'      => $df['label'] !== '' ? $df['label'] : ($base['label'] ?? $df['field']),
                'designType' => $df['inputType'],
            ]);
            return $this->emitFormField($f, $tableName, $mode, $defaultSpan);
        };

        $walk = function (array $components, bool $insideForm) use (&$walk, $emitField, $tableName, $mode, $sticky, $requiredHint, &$buttonsRendered): string {
            $html = '';
            foreach ($components as $c) {
                if (!is_array($c) || (($c['visibility']['visible'] ?? true) === false)) continue;
                $type = (string) ($c['type'] ?? '');
                $p = (array) ($c['props'] ?? []);
                $children = (array) ($c['children'] ?? []);
                switch ($type) {
                    case 'breadcrumb':
                        break; // rendered in the page head
                    case 'section':
                        $heading = trim((string) ($p['heading'] ?? $c['label'] ?? ''));
                        $icon = trim((string) ($p['icon'] ?? ''));
                        $iconHtml = $icon !== '' ? '<i class="' . htmlspecialchars($this->iconClass($icon)) . '"></i>' : '';
                        $plain = ($p['variant'] ?? 'card') === 'plain';
                        // Width span (Size & layout) + header height/style.
                        $secStyle = $this->spanStyleAttr($p);
                        $hh = (string) ($p['headerHeight'] ?? 'md');
                        if (!in_array($hh, ['sm', 'md', 'lg'], true)) $hh = 'md';
                        $hStyle = $this->headerStyleAttr($p);
                        // Section body is a 12-col grid so inner components span.
                        $bodyBg = $this->sanitizeCssValue((string) ($p['bodyBg'] ?? ''));
                        $bodyPad = $this->sanitizeCssValue((string) ($p['bodyPadding'] ?? ''));
                        $bodySt = '';
                        if ($bodyBg !== '') $bodySt .= '--sec-body-bg:' . $bodyBg . ';';
                        if ($bodyPad !== '') $bodySt .= 'padding:' . $bodyPad . ';';
                        $bodyStyleAttr = $bodySt !== '' ? ' style="' . htmlspecialchars($bodySt) . '"' : '';
                        $body = '<div class="pp-form-body"' . $bodyStyleAttr . '>' . $walk($children, false) . '</div>';
                        if (!empty($p['foldable'])) {
                            // Native collapsible: <details> — no JS, keyboard-accessible.
                            $openAttr = empty($p['closedInitially']) ? ' open' : '';
                            $summary = '<summary class="pp-form-head pp-fold-summary hh-' . $hh . '"' . $hStyle . '>' . $iconHtml
                                . '<h3>' . $this->chromeHeadingHtml($heading !== '' ? $heading : 'Section') . '</h3>'
                                . '<i class="bi bi-chevron-down pp-fold-caret"></i></summary>';
                            $inner = '<div class="pp-fold-body">' . $body . '</div>';
                            $html .= '<details class="pp-fold' . ($plain ? ' is-plain' : ' ppg-form-section') . '"' . $secStyle . $openAttr . '>' . $summary . $inner . '</details>' . "\n";
                            break;
                        }
                        $head = $heading !== ''
                            ? '<div class="pp-form-head hh-' . $hh . '"' . $hStyle . '>' . $iconHtml . '<h3>' . $this->chromeHeadingHtml($heading) . '</h3></div>'
                            : '';
                        $html .= $plain
                            ? '<div class="pp-section-plain"' . $secStyle . '>' . $head . $body . '</div>' . "\n"
                            : '<div class="ppg-form-section"' . $secStyle . '>' . $head . $body . '</div>' . "\n";
                        break;
                    case 'form':
                        $labelLeft = (($p['labelPosition'] ?? 'top') === 'left') ? ' pp-label-left' : '';
                        $gap = (string) ($p['gap'] ?? 'normal');
                        $gapCls = in_array($gap, ['tight', 'compact', 'roomy'], true) ? " pp-gap-$gap" : '';
                        $html .= '<div class="ppg-form-grid' . $labelLeft . $gapCls . '">' . "\n" . $walk($children, true) . '</div>' . "\n";
                        break;
                    case 'field':
                        $html .= $emitField($c);
                        break;
                    case 'heading':
                        // Same class as the canvas (.ppd-c-heading) so it looks
                        // identical; colour/align come from the wrapper (Style panel).
                        $level = (string) ($p['level'] ?? 'h2');
                        if (!in_array($level, ['h1', 'h2', 'h3'], true)) $level = 'h2';
                        $html .= $this->wrapComponent($c, "<$level class=\"ppd-c-heading $level\">" . htmlspecialchars((string) ($p['text'] ?? $c['label'] ?? '')) . "</$level>") . "\n";
                        break;
                    case 'text':
                        $html .= $this->wrapComponent($c, '<div class="ppd-c-text">' . htmlspecialchars((string) ($p['text'] ?? '')) . '</div>') . "\n";
                        break;
                    case 'divider':
                    case 'alert':
                        // Rendered by ComponentRenderer (honours label / tone /
                        // icon) — matches the canvas.
                        $html .= $this->wrapComponent($c, ComponentRenderer::render($c, static fn (): string => '')) . "\n";
                        break;
                    case 'buttons':
                        $html .= $this->renderFormButtons($p, $tableName, $mode, $sticky, $requiredHint);
                        $buttonsRendered = true;
                        break;
                    default:
                        // Any other dragged component (alert, card, progress,
                        // stat-card, tabs, timeline, badge, image, html…) →
                        // rendered by ComponentRenderer, mirroring the canvas.
                        if (ComponentRenderer::handles($type)) {
                            $rc = fn (array $node): string => $walk((array) ($node['children'] ?? []), $insideForm);
                            $html .= $this->wrapComponent($c, ComponentRenderer::render($c, $rc));
                        } elseif ($children !== []) {
                            $html .= $walk($children, $insideForm);
                        }
                }
            }
            return $html;
        };

        $body = $walk((array) ($design['components'] ?? []), false);
        if (!$buttonsRendered) {
            $body .= $this->renderFormButtons([], $tableName, $mode, $sticky, $requiredHint);
        }
        // Top-level components live in a 12-col grid so sections (and any
        // component with a width span) can be 6/12, 4/12, … side by side.
        return '<div class="pp-form-body">' . $body . '</div>';
    }

    /** Client behaviour for generated forms — flags come from the designed form component. */
    private function formBehaviourScript(array $formProps, bool $miscWarn, string $vrulesJson = '{}'): string
    {
        $flag = static fn ($v, bool $d): string => (($v ?? $d) ? 'true' : 'false');
        $validate = $flag($formProps['clientValidation'] ?? null, true);
        $warn = (($formProps['warnUnsaved'] ?? true) || $miscWarn) ? 'true' : 'false';
        $keys = $flag($formProps['hotkeys'] ?? null, true);
        $focus = $flag($formProps['autofocusFirst'] ?? null, true);

        // In-page validator: mirrors the server-side rules onto a compact
        // vocabulary. PP_VRULES = { field: { label, rules:[ {…proj, msg} ] } }.
        return <<<HTML
<script>window.PP_VRULES=$vrulesJson;(function(){var f=document.getElementById('ppMainForm');if(!f)return;var cfg={validate:$validate,warn:$warn,keys:$keys,focus:$focus};
if(cfg.focus){var first=f.querySelector('input:not([type=hidden]):not([disabled]):not([readonly]),select,textarea');if(first)try{first.focus()}catch(e){}}
var dirty=false;f.addEventListener('input',function(){dirty=true});f.addEventListener('submit',function(){dirty=false});
// Unsaved-changes guard. In the Add/Edit-in-popup modal the parent's own SweetAlert2
// guard owns it (no native dialog → no double prompt). On a FULL page: SweetAlert2 for
// in-app link navigation (Save/Cancel/Back keep their explicit intent), PLUS a minimal
// native beforeunload as the last-resort safety net for browser-level exit (tab close /
// reload / back button) — the one case SweetAlert2 cannot intercept and where data loss
// is permanent. It only fires with unsaved edits; normal in-app nav never triggers it.
if(cfg.warn&&!document.body.classList.contains('pp-modal-mode')){
window.addEventListener('beforeunload',function(e){if(dirty){e.preventDefault();e.returnValue='';}});
document.addEventListener('click',function(e){if(!dirty)return;var a=e.target.closest('a[href]');if(!a||a.target==='_blank')return;var h=a.getAttribute('href')||'';if(!h||h.charAt(0)==='#'||/^(javascript:|mailto:|tel:)/i.test(h))return;if(a.closest('.pp-form-actions'))return;e.preventDefault();var url=a.href;if(window.Swal){Swal.fire({title:'Discard unsaved changes?',text:'Anything you entered will be lost.',icon:'warning',showCancelButton:true,confirmButtonText:'Discard',cancelButtonText:'Keep editing',confirmButtonColor:'#dc2626',reverseButtons:true}).then(function(r){if(r.isConfirmed){dirty=false;window.location.href=url;}});}else{dirty=false;window.location.href=url;}},true);}
if(cfg.keys)document.addEventListener('keydown',function(e){if((e.metaKey||e.ctrlKey)&&String(e.key).toLowerCase()==='s'){e.preventDefault();if(f.requestSubmit)f.requestSubmit();else f.submit();}});
function vVal(name){var els=f.querySelectorAll('[name="'+name+'"],[name="'+name+'[]"]');if(!els.length)return'';var e0=els[0];if(e0.type==='checkbox'||e0.type==='radio'){var v=[];els.forEach(function(e){if(e.checked)v.push(e.value)});return v.join(',');}return e0.value;}
function vMins(x){var m=/^(\d{1,2}):(\d{2})/.exec((''+x).trim());if(!m)return null;var h=+m[1],mi=+m[2];if(h>23||mi>59)return null;return h*60+mi;}
function vCast(x,as){if(as==='number')return isNaN(parseFloat(x))?null:parseFloat(x);if(as==='date'){var t=Date.parse(x);return isNaN(t)?null:t;}return ''+x;}
function vCmp(a,b,op){switch(op){case '>':return a>b;case '>=':return a>=b;case '<':return a<b;case '<=':return a<=b;case '==':return a==b;case '!=':return a!=b;}return true;}
function vDay(x){var d=new Date(x);if(isNaN(d))return null;d.setHours(0,0,0,0);return d;}
function vOk(r,val){var empty=(val==null||val==='');var s=''+val;
if(r.required)return!empty;
if(r.checked!=null)return !(empty||s==='0'||s==='false'||s==='off');
if(empty)return true;
if(r.pattern!=null){try{if(!(new RegExp(r.pattern,r.flags||'')).test(s))return false;}catch(e){}}
if(r.json){try{JSON.parse(s);}catch(e){return false;}}
if(r.words!=null){if(s.trim().split(/\s+/).filter(Boolean).length<r.words)return false;}
if(r.minLength!=null&&s.length<r.minLength)return false;
if(r.maxLength!=null&&s.length>r.maxLength)return false;
if(r.number){if(!/^-?\d*\.?\d+$/.test(s.trim()))return false;var n=parseFloat(s);if(r.integer&&Math.floor(n)!==n)return false;if(r.min!=null&&n<r.min)return false;if(r.max!=null&&n>r.max)return false;}
if(r.phone){if(!/^[+()\-.\s0-9]+$/.test(s))return false;var d=s.replace(/\D+/g,'');if(d.length<r.phone.min||d.length>r.phone.max)return false;}
if(r.match!=null){var o=f.querySelector('[name="'+r.match+'"]');if(o&&s!==o.value)return false;}
if(r.in){var vv=r.ci?s.toLowerCase():s;var lst=r.ci?r.in.map(function(x){return(''+x).toLowerCase();}):r.in;if(lst.indexOf(vv)<0)return false;}
if(r.luhn){var dd=s.replace(/\D+/g,'');if(dd.length<12||dd.length>19)return false;var sum=0,alt=false;for(var i=dd.length-1;i>=0;i--){var q=+dd[i];if(alt){q*=2;if(q>9)q-=9;}sum+=q;alt=!alt;}if(sum%10!==0)return false;}
if(r.dateValid){var dv=vDay(s);if(!dv)return false;var td=vDay(new Date());
if(r.dateMin!=null){var mn=vDay(r.dateMin);if(mn&&dv<mn)return false;}
if(r.dateMax!=null){var mx=vDay(r.dateMax);if(mx&&dv>mx)return false;}
if(r.notFuture&&dv>td)return false;if(r.notPast&&dv<td)return false;
if(r.minAge!=null){var cut=new Date(td.getFullYear()-r.minAge,td.getMonth(),td.getDate());if(dv>cut)return false;}}
if(r.timeValid){var tm=vMins(s);if(tm==null)return false;if(r.timeMin!=null){var lo=vMins(r.timeMin);if(lo!=null&&tm<lo)return false;}if(r.timeMax!=null){var hi=vMins(r.timeMax);if(hi!=null&&tm>hi)return false;}}
if(r.password){var p=r.password;if(s.length<p.minLength)return false;if(p.upper&&!/\p{Lu}/u.test(s))return false;if(p.lower&&!/\p{Ll}/u.test(s))return false;if(p.digit&&!/\d/.test(s))return false;if(p.symbol&&!/[^\p{L}\p{N}]/u.test(s))return false;}
if(r.compare){var oc=f.querySelector('[name="'+r.compare.field+'"]');if(oc){var A=vCast(s,r.compare.as),B=vCast(oc.value,r.compare.as);if(A!=null&&B!=null&&!vCmp(A,B,r.compare.op))return false;}}
if(r.different!=null){var od=f.querySelector('[name="'+r.different+'"]');if(od&&s===od.value)return false;}
return true;}
function vWhen(w){if(!w||!w.field)return true;var ov=''+vVal(w.field),val=w.value==null?'':''+w.value;switch(w.op){case'!=':return ov!==val;case'empty':return ov.trim()==='';case'notEmpty':return ov.trim()!=='';case'in':return val.split(',').map(function(s){return s.trim();}).indexOf(ov)>-1;case'contains':return val!==''&&ov.toLowerCase().indexOf(val.toLowerCase())>-1;default:return ov===val;}}
function vRun(field){var spec=window.PP_VRULES[field];if(!spec)return'';var val=vVal(field);for(var i=0;i<spec.rules.length;i++){var r=spec.rules[i];if(r.when&&!vWhen(r.when))continue;if(!vOk(r,val))return r.msg;}return'';}
// Inline per-field error: reuse the field wrapper, drop a message row under it.
function vFldOf(el){return el.closest('.pp-fld');}
function vErr(fl){if(!fl)return null;var e=fl.querySelector('.pp-fld-err');if(!e){e=document.createElement('div');e.className='pp-fld-err';e.setAttribute('role','alert');e.innerHTML='<i class="bi bi-exclamation-circle"></i><span class="pp-fld-err-t"></span>';fl.appendChild(e);}return e;}
function vNameOf(el){return(el.name||'').replace(/\[\]$/,'');}
// Combined message for an element: native HTML5 constraint first, then our rules.
function vMsgFor(el){if(el.disabled)return'';if(el.willValidate&&!el.checkValidity())return el.validationMessage||'Please check this field.';var n=vNameOf(el);var m=(n&&window.PP_VRULES[n])?vRun(n):'';if(m===''&&el._ppUniqueMsg)m=el._ppUniqueMsg;return m;}
// Async "unique" check: fields with a Unique rule are verified against the
// server (api/validate-unique.php) on blur / debounced typing, so a duplicate is
// flagged inline before submit. The server still enforces it on save.
function vUQ(){return window.PP_UNIQUE;}function vIsUniq(el){var u=vUQ();return !!(u&&u.fields&&u.fields.indexOf(vNameOf(el))>-1);}
function vUniqRule(field){var s=window.PP_VRULES[field];if(!s)return null;for(var i=0;i<s.rules.length;i++)if(s.rules[i].unique)return s.rules[i];return null;}
function vUniqCheck(el){var u=vUQ();if(!u||!vIsUniq(el))return;var name=vNameOf(el),rule=vUniqRule(name);if(rule&&rule.when&&!vWhen(rule.when)){el._ppUniqueMsg='';vApply(el,vMsgFor(el));return;}var val=''+vVal(name);if(val.trim()===''){el._ppUniqueMsg='';vApply(el,vMsgFor(el));return;}var id=(new URLSearchParams(location.search)).get('id')||'';var seq=(el._ppUqSeq=(el._ppUqSeq||0)+1);el.setAttribute('aria-busy','true');var body='table='+encodeURIComponent(u.table)+'&field='+encodeURIComponent(name)+'&value='+encodeURIComponent(val)+'&id='+encodeURIComponent(id);fetch((window.PP_BASE||'')+'/api/validate-unique.php',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body,credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){if(seq!==el._ppUqSeq)return;el.removeAttribute('aria-busy');el._ppUniqueMsg=(j&&j.ok&&j.available===false)?((rule&&rule.msg)||'This value is already taken.'):'';vApply(el,vMsgFor(el));}).catch(function(){el.removeAttribute('aria-busy');});}
function vApply(el,msg){var fl=vFldOf(el);el._ppMsg=msg||'';if(!fl)return;var er=vErr(fl);var t=er.querySelector('.pp-fld-err-t');if(msg){fl.classList.add('pp-invalid');if(t)t.textContent=msg;er.hidden=false;el.setAttribute('aria-invalid','true');if(!er.id)er.id='pperr_'+Math.random().toString(36).slice(2,8);el.setAttribute('aria-describedby',er.id);}else{fl.classList.remove('pp-invalid');if(t)t.textContent='';er.hidden=true;el.removeAttribute('aria-invalid');}}
function vCheck(el){if(!el.name||el.disabled)return;if(!/^(INPUT|SELECT|TEXTAREA)\$/.test(el.tagName))return;vApply(el,vMsgFor(el));}
if(cfg.validate){f.setAttribute('novalidate','novalidate');
// Reward early, punish late: check a field when the user leaves it (blur); once
// it shows an error, re-check as they type so it clears the instant it is fixed.
// A pristine field is never nagged mid-typing.
f.addEventListener('focusout',function(e){var el=e.target;if(!el.name)return;el._ppTouched=true;vCheck(el);vUniqCheck(el);});
f.addEventListener('change',function(e){var el=e.target;if(el.name&&(el.type==='checkbox'||el.type==='radio'||el.tagName==='SELECT')){el._ppTouched=true;vCheck(el);}});
f.addEventListener('input',function(e){var el=e.target;if(!el._ppTouched)return;if(vIsUniq(el)){el._ppUniqueMsg='';vCheck(el);clearTimeout(el._ppUqT);el._ppUqT=setTimeout(function(){vUniqCheck(el);},350);return;}var fl=vFldOf(el);if(fl&&fl.classList.contains('pp-invalid'))vCheck(el);});
f.addEventListener('submit',function(e){var bad=[];
f.querySelectorAll('input,select,textarea').forEach(function(el){if(el.disabled||!el.name)return;el._ppTouched=true;var msg=vMsgFor(el);vApply(el,msg);if(msg&&bad.indexOf(el)<0)bad.push(el);});
var sum=document.getElementById('ppVSummary');
if(bad.length){e.preventDefault();dirty=true;
if(sum){var ul=sum.querySelector('ul');ul.innerHTML='';bad.forEach(function(el){var fl=vFldOf(el);
var lbl=fl&&fl.querySelector('label')?fl.querySelector('label').textContent.replace('*','').trim():(el.name||'Field');
var li=document.createElement('li');var a=document.createElement('a');a.href='#';a.textContent=lbl+': '+(el._ppMsg||el.validationMessage||'invalid value');
a.addEventListener('click',function(ev){ev.preventDefault();try{el.focus()}catch(_){}});li.appendChild(a);ul.appendChild(li);});
sum.classList.add('show');sum.scrollIntoView({behavior:'smooth',block:'nearest'});}
try{bad[0].focus()}catch(e2){}}
else if(sum){sum.classList.remove('show');}});}
})();</script>
HTML;
    }

    private function generateTableFormPage(string $outputDir, string $tableName, array $fields, string $mode): array
    {
        $this->log("Generating {$mode} page for table: $tableName");
        $this->currentFormTable = $tableName;
        $dir = $outputDir . '/generated/' . $tableName;
        if (!is_dir($dir)) mkdir($dir, 0755, true);

        $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));
        $modeLabel = ($mode === 'add') ? 'Add' : 'Edit';
        $title = "$modeLabel $tableLabel";   // default; overridden by the design title below

        // Primary-key handling for this form. `$pkName` is the table's REAL key
        // (id, std_id, customer_id…). An AUTO_INCREMENT key is never written by the
        // app; a MANUAL key is user-supplied on Add only. `$pkWritableOnAdd` is the
        // one case the PK belongs in the collected INSERT data.
        $pkName = $this->primaryKeyFor($tableName);
        $pkAuto = $this->primaryKeyIsAutoFor($tableName);
        $pkWritableOnAdd = (!$pkAuto && $mode === 'add');

        // ── Design (saved page or default template) ──
        $design = $this->designFor($tableName, $mode);
        // Prefer the Designer's page title (Properties panel → Page title) as the
        // pp_page_title() fallback; the Misc → Label Editor → Page Titles dictionary
        // still overrides it per-language. Falls back to the table-derived name.
        $designTitle = str_replace(["\r", "\n"], ' ', trim((string) ($design['title'] ?? '')));
        if ($designTitle !== '') $title = $designTitle;
        $titleAttr = addslashes($title);                       // safe inside pp_page_title('…')
        $titleHtml = htmlspecialchars($title, ENT_QUOTES);     // safe inside <title></title>
        $props  = $this->componentProps($design);
        $formProps = (array) ($props['form'] ?? []);
        $pgChrome = $this->pageChromeHtml($design);   // theme/width/align + custom CSS/JS
        $labelLeft = (($formProps['labelPosition'] ?? 'top') === 'left') ? ' pp-label-left' : '';
        $gapClass = (($formProps['gap'] ?? 'normal') === 'compact') ? ' pp-gap-compact' : '';

        // Default span for fields without their own (12-column grid; may be per-device)
        $fieldSpanProp = $formProps['fieldSpan'] ?? 12;
        $defaultSpan = (int) (is_array($fieldSpanProp) ? ($fieldSpanProp['desktop'] ?? 12) : $fieldSpanProp);
        if ($defaultSpan < 1 || $defaultSpan > 12) $defaultSpan = 12;

        // Field components placed in the designer (order, labels, spans, placeholders…)
        $designFields = $this->designFormFields($design);

        // Per-field validation rules (Designer → field → Validation section).
        // Same map drives the client-side validator (below) and the server-side
        // pass (injected into $phpLogic). Empty when no field carries rules.
        $vrules = [];
        $vlabels = [];
        foreach ($designFields as $df) {
            $fn = (string) ($df['field'] ?? '');
            if ($fn === '') continue;
            $rules = self::sanitizeValidations($df['validations'] ?? []);
            if ($rules !== []) {
                $vrules[$fn] = $rules;
                $vlabels[$fn] = (string) ($df['label'] ?? $fn);
            }
        }
        // Field-level validation (Field Controls modal → Validation tab) applies to
        // every page. Merge it in for any column a designed field component did not
        // already carry rules for — the component-level rules above win.
        foreach ((array) ($this->tableFields[$tableName] ?? []) as $tf) {
            $fn = (string) ($tf['name'] ?? '');
            if ($fn === '' || isset($vrules[$fn])) continue;
            $rules = self::sanitizeValidations($tf['validations'] ?? []);
            if ($rules !== []) {
                $vrules[$fn] = $rules;
                $vlabels[$fn] = (string) ($tf['label'] ?? $fn);
            }
        }
        $clientVRules = $this->buildClientVRules($vrules, $vlabels);
        $clientVRulesJson = $vrules === [] ? '{}'
            : json_encode($clientVRules, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);

        // Lookup fields on this form → PP_LOOKUP_FIELDS drives the client-side
        // machinery: cascade reload (parent change), popup add/edit refresh,
        // and the AJAX suggestion box.
        $deps = [];
        foreach ($this->lookupFieldsFor($tableName) as $depName => $depLk) {
            $hasParent = ($depLk['parentField'] ?? '') !== '' && ($depLk['filterField'] ?? '') !== '';
            // Sibling fields the WHERE reads via :field.name — so the control can
            // send them and re-filter this dropdown when any of them changes.
            $watch = \PpPhp\Core\SqlVars::fieldRefs((string) ($depLk['where'] ?? ''));
            $deps[$depName] = ['id' => $tableName . '.' . $depName]
                + ($hasParent ? ['parent' => $depLk['parentField']] : [])
                + ($watch !== [] ? ['watch' => $watch] : []);
        }
        $depLookupsScript = $deps === [] ? ''
            : '<script>window.PP_LOOKUP_FIELDS = ' . json_encode($deps, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG) . ';</script>';

        // Fields with a Unique rule → async availability check (api/validate-unique.php).
        $uniqueFields = $this->uniqueFieldsFor($tableName);
        $depLookupsScript .= $uniqueFields === [] ? ''
            : '<script>window.PP_UNIQUE = ' . json_encode(['table' => $tableName, 'fields' => array_values($uniqueFields)], JSON_UNESCAPED_SLASHES | JSON_HEX_TAG) . ';</script>';

        // Breadcrumb — designed items when present, else a default (Home › Table › mode)
        // so a never-designed form page still gets one.
        $crumbItems = array_values(array_filter(
            array_map(static fn($s) => trim((string) $s), (array) ($props['breadcrumb']['items'] ?? [])),
            static fn(string $s): bool => $s !== ''
        ));
        $crumbDefaulted = ($crumbItems === []);
        if ($crumbItems === []) $crumbItems = ['Home', $tableLabel, $modeLabel];
        {
            $parts = [];
            $lastIdx = count($crumbItems) - 1;
            foreach ($crumbItems as $i => $item) {
                $safe = addslashes(htmlspecialchars($item));
                if ($i === 0) {
                    $homeExpr = ($crumbDefaulted || $item === 'Home')
                        ? 'htmlspecialchars(pp_t(\'ui.home\', \'Home\'))'
                        : "'$safe'";
                    $parts[] = '\'<a href="\' . APP_BASE . \'/">\' . ' . $homeExpr . ' . \'</a>\'';
                } elseif ($i === 1 && $lastIdx > 1) {
                    // Table crumb → translatable caption when it's the table's own
                    // label (default or scaffolded); a custom crumb stays literal.
                    $tblExpr = ($crumbDefaulted || $item === $tableLabel)
                        ? 'htmlspecialchars(pp_table_caption(\'' . $tableName . '\', \'' . $safe . '\'))'
                        : "'$safe'";
                    $parts[] = '\'<a href="\' . APP_BASE . \'/generated/' . $tableName . '/list.php">\' . ' . $tblExpr . ' . \'</a>\'';
                } elseif ($i === $lastIdx && ($crumbDefaulted || $item === $modeLabel)) {
                    // Mode crumb (Add / Edit) → translatable chrome.
                    $modeKey = ($mode === 'add') ? 'ui.add' : 'ui.edit';
                    $parts[] = '\'<span>\' . htmlspecialchars(pp_t(\'' . $modeKey . '\', \'' . $safe . '\')) . \'</span>\'';
                } else {
                    $parts[] = "'<span>$safe</span>'";
                }
            }
            $crumbArg = '\'<nav class="pp-breadcrumb">\' . ' . implode(' . \'' . $this->crumbSeparator($props) . '\' . ', $parts) . ' . \'</nav>\'';
        }

        $fieldsByName = [];
        foreach ($fields as $f) $fieldsByName[$f['name']] = $f;

        // Designed page → walk the full component tree (sections, form grids,
        // fields, headings, buttons…). No designed fields → flat fallback form
        // from the Fields module.
        $bodyHtml = $this->renderDesignedFormBody($design, $tableName, $fieldsByName, $mode, $formProps);
        if ($bodyHtml === null) {
            // No design → a polished DEFAULT form (matches "Reset to Default"): a
            // titled card, a 2-column grid, long-text fields spanning the full row,
            // and the standard action buttons.
            $fallbackSpan = ($defaultSpan >= 1 && $defaultSpan < 12) ? $defaultSpan : 6;
            $formFields = '';
            foreach ($fields as $f) {
                // Skip an auto/locked primary key from the fallback form (real pk
                // name, not literal 'id'); a manual key on Add stays so the user
                // can supply it.
                if (!($f[$mode] ?? true) || ($f['name'] === $pkName && !$pkWritableOnAdd)) continue;
                $span = in_array(($f['type'] ?? ''), ['textarea', 'html', 'richtext', 'json'], true) ? 12 : $fallbackSpan;
                $f['span'] = $span;
                $formFields .= $this->emitFormField($f, $tableName, $mode, $span);
            }
            $sectionTitle = $mode === 'edit'
                ? '<?= htmlspecialchars(pp_t(\'ui.edit_record\', \'Edit Record\')) ?>'
                : '<?= htmlspecialchars(pp_t(\'ui.new_record\', \'New Record\')) ?>';
            $bodyHtml = '<div class="ppg-form-section"><div class="pp-form-head"><h3>' . $sectionTitle . '</h3></div>'
                . '<div class="ppg-form-grid' . $labelLeft . $gapClass . '">' . "\n"
                . $formFields . '</div></div>' . "\n"
                . $this->renderFormButtons([], $tableName, $mode, false, true);
        }

        // Client behaviour: validation summary, unsaved-changes warning
        // (Misc → warnLeave forces it on), Ctrl/Cmd+S, autofocus. The per-field
        // validation rules ride along as PP_VRULES for the in-page validator.
        $behaviourScript = $this->formBehaviourScript($formProps, !empty($this->miscFor($tableName)['warnLeave']), $clientVRulesJson);

        // Generic plugin assets: every edit control used on this form contributes
        // its declared assets() to the head — new plugins auto-wire with zero
        // build changes (flatpickr is loaded unconditionally elsewhere and skipped).
        $usedControls = [];
        foreach ($designFields as $df) {
            $c = (string) ($df['editControl'] ?? '');
            if ($c !== '') $usedControls[$c] = true;
        }
        foreach ($fields as $f) {
            foreach ([(string) ($f['editControl'] ?? ''), (string) ($f['editAs'] ?? '')] as $c) {
                if ($c !== '') $usedControls[$c] = true;
            }
        }
        [$pluginCss, $pluginJs] = $this->pluginHeadAssets(array_keys($usedControls));

        $vsummaryHtml = (($formProps['clientValidation'] ?? true) !== false)
            ? '<div class="pp-vsummary" id="ppVSummary"><strong><i class="bi bi-exclamation-circle"></i> Please fix the following</strong><ul></ul></div>'
            : '';

        if ($mode === 'edit') {
            $phpLogic = <<<'PHP'
$id = (int) ($_GET['id'] ?? 0);
if (!$id) { header('Location: ' . APP_BASE . '/generated/TABLE/list.php'); exit; }
// Row-Level Security: a user who cannot see this row cannot open it for edit
// either — the empty fetch below redirects them out before any update runs.
$row = \PpPhp\Core\DB::FetchAll("SELECT * FROM `TABLE` WHERE `" . pp_pk('TABLE') . "` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql('TABLE'), [$id]);
if (empty($row)) { $_SESSION['_flash'] = ['message' => 'Record not found.', 'type' => 'error']; header('Location: ' . APP_BASE . '/generated/TABLE/list.php'); exit; }
// Field Encryption: decrypt so the form shows the real value; it is re-encrypted on save.
// Keep the raw (as-stored) row for the audit trail so encrypted columns are never
// written to the log in plaintext.
$__oldRaw = $row[0];
$row = \PpPhp\Core\Auth\FieldCrypto::decryptRow('TABLE', $__oldRaw);
if (!array_key_exists('id', $row)) { $row['id'] = $row[pp_pk('TABLE')] ?? null; }
\PpPhp\Core\Events::fire('tables.TABLE.page.beforeEditLoad', ['record' => &$row]);
$old = $row;
$error = '';
// Record Locking (Misc → Advanced): take the lock while this user edits. If another
// user already holds it, the form is shown read-only with a notice.
$__lock = \PpPhp\Core\Auth\RecordLock::acquire('TABLE', $id);
$__lockedBy = empty($__lock['ok']) ? (string) ($__lock['lockedByName'] ?? 'another user') : '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!\PpPhp\Core\Security::CSRF()->Check($_POST['_token'] ?? '')) { $error = 'Invalid token.'; }
    else {
        $data = [];
PHP;
        } else {
            $phpLogic = <<<'PHP'
$row = [];
// URL prefill: add.php?field=value pre-fills the form — powers master–detail
// "Add for this record" links and shareable prefill URLs. Field-whitelisted;
// beforeAddLoad runs after and may override.
foreach (PP_PREFILL_FIELDS as $__pf) {
    if (isset($_GET[$__pf]) && is_string($_GET[$__pf])) $row[$__pf] = $_GET[$__pf];
}
\PpPhp\Core\Events::fire('tables.TABLE.page.beforeAddLoad', ['defaults' => &$row]);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!\PpPhp\Core\Security::CSRF()->Check($_POST['_token'] ?? '')) { $error = 'Invalid token.'; }
    else {
        $data = [];
PHP;
        }

        foreach ($fields as $f) {
            // Skip the primary key — the DB assigns an auto key, and on Edit the
            // key is the WHERE target, never part of the SET. A MANUAL key on Add
            // is the sole case the user supplies it, so it IS collected.
            if ($f['name'] === $pkName && !$pkWritableOnAdd) continue;
            // Only accept fields actually submitted — a designed form may omit
            // some fields, and absent keys must not wipe existing values.
            // Arrays (checkbox groups posting field[]) are stored comma-separated.
            // (Empty '' → NULL for date/number/etc. columns is handled uniformly
            // at the DB layer — see DB::Insert/Update — so every save path benefits.)
            $phpLogic .= "        if (array_key_exists('{$f['name']}', \$_POST)) { \$v = \$_POST['{$f['name']}']; \$data['{$f['name']}'] = is_array(\$v) ? implode(',', array_filter(array_map('strval', \$v), 'strlen')) : \$v; }\n";
        }

        // File/Image columns on this form — on EDIT save, orphaned uploads (a file
        // removed or replaced) are pruned from storage after the row is written.
        $fileImageCols = [];
        $__ecMap = $this->editControlsFor($tableName);
        foreach ($fields as $f) {
            $fn = (string) ($f['name'] ?? '');
            if ($fn === '' || $fn === $pkName) continue;
            $ctl = (string) ($__ecMap[$fn]['editControl'] ?? $f['editControl'] ?? $f['editAs'] ?? '');
            if ($ctl === 'FileImage') $fileImageCols[] = $fn;
        }
        if ($mode === 'edit' && $fileImageCols !== []) {
            $phpLogic .= "        \$__uploadCols = " . var_export($fileImageCols, true) . ";\n";
        }

        // Designer → field → "AutoUpdate value (edit)": a PHP expression the app
        // owner authored, re-evaluated on every edit save (last-modified stamps,
        // "updated by" ids…). It runs AFTER the POST collector so it always wins
        // over whatever the form submitted, and before validation/hooks so rules
        // and events still see the final value. Add pages use Default value.
        $autoLines = '';
        foreach ($designFields as $df) {
            $fn = (string) ($df['field'] ?? '');
            $raw = trim((string) ($df['autoUpdate'] ?? ''));
            if ($fn === '' || $raw === '' || $fn === $pkName) continue;
            if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $fn)) continue;
            // Edit-only unless the field opts into "Apply on Add too" (a computed
            // column such as Full Name usually wants both).
            if ($mode === 'add' && empty($df['autoUpdateOnAdd'])) continue;
            // Same compiler as Default value: quoted text stays a literal,
            // now()/today() are sugar, real expressions are lint-checked,
            // and anything else degrades to a safe quoted literal.
            $expr = $this->compileDefaultExpr($raw);
            if ($expr === null || str_contains($expr, '?>')) continue;
            $autoLines .= "        \$data['$fn'] = ($expr);\n";
        }
        if ($autoLines !== '') {
            // $field('other_column') reads another field of THIS form: the value
            // just submitted, else (edit) the value already stored, else the
            // fallback. Safe for columns the form doesn't post — no undefined-key
            // warnings — while raw $data['col'] stays available for power users.
            $phpLogic .= "        \$__autoOld = " . ($mode === 'edit' ? '$old' : '[]') . ";\n";
            $phpLogic .= "        \$field = static function (string \$__k, \$__d = '') use (&\$data, \$__autoOld) {\n"
                       . "            return array_key_exists(\$__k, \$data) ? \$data[\$__k] : (\$__autoOld[\$__k] ?? \$__d);\n"
                       . "        };\n";
            $phpLogic .= $autoLines;
        }

        // Built-in validation rules (Designer → field → Validation) as PHP
        // literals, consumed by the validation pass injected into the hooks below.
        $phpLogic .= "        \$__vrules = " . self::phpArrayLiteral($vrules) . ";\n";
        $phpLogic .= "        \$__vlabels = " . self::phpArrayLiteral($vlabels) . ";\n";

        if ($mode === 'add') {
            $phpLogic .= <<<'PHP'
        // Built-in field validation rules run first; custom .server.validate
        // hooks (below) can still add or override per-field messages.
        $__fieldErrors = [];
        if (class_exists('\PpPhp\Core\Validation\Validator')) {
            foreach ($__vrules as $__vf => $__vr) {
                if (!array_key_exists($__vf, $data)) continue;
                $__ve = \PpPhp\Core\Validation\Validator::validate($__vr, $data[$__vf], $__vlabels[$__vf] ?? $__vf, $data, ['table' => 'TABLE', 'field' => $__vf, 'id' => null]);
                if ($__ve !== null && !isset($__fieldErrors[$__vf])) { $__fieldErrors[$__vf] = $__ve; }
            }
        }
        // Field-level server hooks (validate / beforeSet)
        foreach (array_keys($data) as $__f) {
            $__err = \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.validate", ['value' => $data[$__f], 'record' => $data, 'id' => null]);
            if (is_string($__err) && $__err !== '') { $__fieldErrors[$__f] = $__err; }
            $__new = \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.beforeSet", ['value' => &$data[$__f], 'record' => $data]);
            if ($__new !== null) { $data[$__f] = $__new; }
        }
        $__hook = \PpPhp\Core\Events::fire('tables.TABLE.page.beforeAddSave', ['data' => &$data, 'errors' => $__fieldErrors]);
        if (is_array($__hook)) { $__fieldErrors = array_merge($__fieldErrors, $__hook); }
        if ($__hook === false) {
            $error = 'Save was cancelled by a business rule.';
        } elseif (!empty($__fieldErrors)) {
            $error = implode(' — ', array_map(static fn($f, $m) => "$f: $m", array_keys($__fieldErrors), $__fieldErrors));
        } else {
            $data = \PpPhp\Core\Auth\FieldCrypto::encryptRow('TABLE', $data);
            $id = \PpPhp\Core\DB::Insert('TABLE', $data);
            if ($id) {
                \PpPhp\Core\Auth\AuditLog::created('TABLE', $id, $data);
                \PpPhp\Core\Events::fire('tables.TABLE.page.afterAddSave', ['record' => $data, 'id' => $id]);
                foreach (array_keys($data) as $__f) {
                    \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.afterSet", ['value' => $data[$__f], 'id' => $id, 'record' => $data]);
                }
                $_SESSION['_flash'] = ['message' => 'Record created successfully.', 'type' => 'success'];
                // Save & New keeps the user on the add page for rapid entry
                header('Location: ' . APP_BASE . (($_POST['__after'] ?? '') === 'new' ? '/generated/TABLE/add.php' : '/generated/TABLE/list.php'));
                exit;
            }
            $error = 'Failed to create record.';
        }
    }
}
PHP;
        } else {
            $phpLogic .= <<<'PHP'
        // Built-in field validation rules run first; custom .server.validate
        // hooks (below) can still add or override per-field messages.
        $__fieldErrors = [];
        if (class_exists('\PpPhp\Core\Validation\Validator')) {
            foreach ($__vrules as $__vf => $__vr) {
                if (!array_key_exists($__vf, $data)) continue;
                $__ve = \PpPhp\Core\Validation\Validator::validate($__vr, $data[$__vf], $__vlabels[$__vf] ?? $__vf, $data, ['table' => 'TABLE', 'field' => $__vf, 'id' => $id]);
                if ($__ve !== null && !isset($__fieldErrors[$__vf])) { $__fieldErrors[$__vf] = $__ve; }
            }
        }
        // Field-level server hooks (validate / beforeSet)
        foreach (array_keys($data) as $__f) {
            $__err = \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.validate", ['value' => $data[$__f], 'record' => $data, 'id' => $id]);
            if (is_string($__err) && $__err !== '') { $__fieldErrors[$__f] = $__err; }
            $__new = \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.beforeSet", ['value' => &$data[$__f], 'record' => $data]);
            if ($__new !== null) { $data[$__f] = $__new; }
        }
        $__hook = \PpPhp\Core\Events::fire('tables.TABLE.page.beforeEditSave', ['data' => &$data, 'id' => $id, 'old' => $old, 'errors' => $__fieldErrors]);
        if (is_array($__hook)) { $__fieldErrors = array_merge($__fieldErrors, $__hook); }
        if ($__hook === false) {
            $error = 'Save was cancelled by a business rule.';
        } elseif (!empty($__fieldErrors)) {
            $error = implode(' — ', array_map(static fn($f, $m) => "$f: $m", array_keys($__fieldErrors), $__fieldErrors));
        } elseif (\PpPhp\Core\Auth\RecordLock::heldByOther('TABLE', $id)) {
            $error = 'This record is being edited by someone else — your changes were not saved.';
        } else {
            $__uploadNew = $data;   // pre-encryption snapshot for storage GC
            $data = \PpPhp\Core\Auth\FieldCrypto::encryptRow('TABLE', $data);
            $affected = \PpPhp\Core\DB::Update('TABLE', $data, [pp_pk('TABLE') => $id]);
            if ($affected !== false) {
                // Prune storage objects for any File/Image column whose file was
                // removed or replaced (compares the old row to the new values).
                foreach (($__uploadCols ?? []) as $__uc) {
                    if (array_key_exists($__uc, $__uploadNew)) pp_upload_prune($old[$__uc] ?? '', $__uploadNew[$__uc] ?? '');
                }
                \PpPhp\Core\Auth\AuditLog::updated('TABLE', $id, $__oldRaw ?? $old, $data);
                \PpPhp\Core\Auth\RecordLock::release('TABLE', $id);
                \PpPhp\Core\Events::fire('tables.TABLE.page.afterEditSave', ['record' => array_merge($old, $data), 'id' => $id, 'old' => $old]);
                foreach (array_keys($data) as $__f) {
                    \PpPhp\Core\Events::fire("tables.TABLE.fields.{$__f}.server.afterSet", ['value' => $data[$__f], 'id' => $id, 'record' => $data]);
                }
                $_SESSION['_flash'] = ['message' => 'Record updated successfully.', 'type' => 'success'];
                header('Location: ' . APP_BASE . (($_POST['__after'] ?? '') === 'new' ? '/generated/TABLE/add.php' : '/generated/TABLE/list.php'));
                exit;
            }
            $error = 'Failed to update record.';
        }
    }
}
PHP;
        }

        // Lookup Wizard "Prevent duplicate values": reject the save when another
        // record of THIS table already uses the submitted value.
        $dupPhp = '';
        foreach ($this->lookupFieldsFor($tableName) as $dupF => $dupLk) {
            if (($dupLk['preventDup'] ?? 'no') !== 'yes') continue;
            $dupPhp .= $mode === 'edit'
                ? "        if ((string) (\$data['$dupF'] ?? '') !== '' && (int) \\PpPhp\\Core\\DB::FetchOne(\"SELECT COUNT(*) FROM `TABLE` WHERE `$dupF` = ? AND `\" . pp_pk('TABLE') . \"` <> ?\", [\$data['$dupF'], \$id]) > 0) { \$__fieldErrors['$dupF'] = 'This value is already used by another record.'; }\n"
                : "        if ((string) (\$data['$dupF'] ?? '') !== '' && (int) \\PpPhp\\Core\\DB::FetchOne(\"SELECT COUNT(*) FROM `TABLE` WHERE `$dupF` = ?\", [\$data['$dupF']]) > 0) { \$__fieldErrors['$dupF'] = 'This value is already used by another record.'; }\n";
        }
        if ($dupPhp !== '') {
            $hookMarker = "\$__hook = \\PpPhp\\Core\\Events::fire('tables.TABLE.page.before" . ($mode === 'edit' ? 'Edit' : 'Add') . "Save'";
            $phpLogic = str_replace($hookMarker, $dupPhp . '        ' . $hookMarker, $phpLogic);
        }

        // Lookup Wizard popup flow ("add/edit values on the fly"): a popup save
        // notifies the opener (which refreshes its options) and closes itself.
        $popupExit = "if (!empty(\$_POST['pp_popup'])) { echo '<!doctype html><script>try{window.opener&&window.opener.postMessage({ppLookupSaved:1},\"*\")}catch(e){}window.close();</script>Saved — you can close this window.'; exit; }\n                ";
        $phpLogic = str_replace(
            "\$_SESSION['_flash'] = ['message' => 'Record created successfully.'",
            $popupExit . "\$_SESSION['_flash'] = ['message' => 'Record created successfully.'",
            $phpLogic
        );
        $phpLogic = str_replace(
            "\$_SESSION['_flash'] = ['message' => 'Record updated successfully.'",
            $popupExit . "\$_SESSION['_flash'] = ['message' => 'Record updated successfully.'",
            $phpLogic
        );

        // ── Add Settings (pages.json → settings.add) — add pages only ──
        if ($mode === 'add') {
            $add = $this->addSettingsFor($tableName);
            // "After record is added" → post-save redirect. Save & New (__after=new)
            // always wins (explicit button), otherwise follow the configured target.
            $afterDest = [
                'list' => "'/generated/TABLE/list.php'",
                'next' => "'/generated/TABLE/add.php'",
                'edit' => "'/generated/TABLE/edit.php?id=' . \$id",
                'view' => "'/generated/TABLE/view.php?id=' . \$id",
            ][$add['afterAdd']];
            $phpLogic = str_replace(
                "=== 'new' ? '/generated/TABLE/add.php' : '/generated/TABLE/list.php')",
                "=== 'new' ? '/generated/TABLE/add.php' : $afterDest)",
                $phpLogic
            );
            // Popup (modal) mode: after a successful add in the modal, signal the
            // opener list instead of a normal redirect. closePopup (or afterAdd=list)
            // → close + reload; next → reload the modal with a blank form; edit/view
            // → parent navigates to that page. Save & New always reloads the modal.
            $cp = $add['closePopup'] ? '1' : '0';
            $aa = $add['afterAdd'];
            $modalExit = "if (!empty(\$_POST['pp_modal'])) { \$__n = (\$_POST['__after'] ?? '') === 'new' ? 'next' : '$aa'; if ('$cp' === '1' && (\$_POST['__after'] ?? '') !== 'new') \$__n = 'list'; if (\$__n === 'next') { header('Location: ' . APP_BASE . '/generated/TABLE/add.php?pp_modal=1'); exit; } \$__u = \$__n === 'edit' ? APP_BASE . '/generated/TABLE/edit.php?id=' . \$id : (\$__n === 'view' ? APP_BASE . '/generated/TABLE/view.php?id=' . \$id : ''); echo '<!doctype html><script>parent.postMessage({ppFormModal:' . json_encode(\$__n === 'edit' || \$__n === 'view' ? 'navigate' : 'close-reload') . ',url:' . json_encode(\$__u) . '},\"*\");</script>'; exit; }\n                ";
            $phpLogic = str_replace(
                "\$_SESSION['_flash'] = ['message' => 'Record created successfully.'",
                $modalExit . "\$_SESSION['_flash'] = ['message' => 'Record created successfully.'",
                $phpLogic
            );
        }

        // ── Edit Settings (pages.json → settings.edit) — edit pages only ──
        if ($mode === 'edit') {
            $edit = $this->editSettingsFor($tableName);
            // "After record is updated" → post-save redirect (list / stay / view;
            // next/previous fall back to list for now).
            $afterMap = [
                'list' => "'/generated/TABLE/list.php'",
                'stay' => "'/generated/TABLE/edit.php?id=' . \$id",
                'view' => "'/generated/TABLE/view.php?id=' . \$id",
            ];
            $afterDest = $afterMap[$edit['afterEdit']] ?? $afterMap['list'];
            $phpLogic = str_replace(
                "=== 'new' ? '/generated/TABLE/add.php' : '/generated/TABLE/list.php')",
                "=== 'new' ? '/generated/TABLE/add.php' : $afterDest)",
                $phpLogic
            );
            // Popup (modal) mode after a successful edit: closePopup / afterEdit=list
            // → close + reload; stay → reload the modal on this record; view → parent
            // navigates to the view page.
            $cp = $edit['closePopup'] ? '1' : '0';
            $ae = in_array($edit['afterEdit'], ['list', 'stay', 'view'], true) ? $edit['afterEdit'] : 'list';
            $modalExit = "if (!empty(\$_POST['pp_modal'])) { \$__n = '$ae'; if ('$cp' === '1') \$__n = 'list'; if (\$__n === 'stay') { header('Location: ' . APP_BASE . '/generated/TABLE/edit.php?id=' . \$id . '&pp_modal=1'); exit; } \$__u = \$__n === 'view' ? APP_BASE . '/generated/TABLE/view.php?id=' . \$id : ''; echo '<!doctype html><script>parent.postMessage({ppFormModal:' . json_encode(\$__n === 'view' ? 'navigate' : 'close-reload') . ',url:' . json_encode(\$__u) . '},\"*\");</script>'; exit; }\n                ";
            $phpLogic = str_replace(
                "\$_SESSION['_flash'] = ['message' => 'Record updated successfully.'",
                $modalExit . "\$_SESSION['_flash'] = ['message' => 'Record updated successfully.'",
                $phpLogic
            );
        }

        // Tables module relations on this form page: detail sections below the
        // form (inline grid / popup button / icon link per Display option) and
        // the parent-record card above it. On add pages the master key is
        // empty until saved (the grid shows its save-first note) unless the FK
        // arrived via URL prefill — then the master card resolves immediately.
        $relGridsHtml  = $this->relationGridsHtml($tableName, $mode);
        $relMasterHtml = $this->relationMasterCardHtml($tableName, $mode);

        // Designer → field → "Default value": resolve the designed defaults into
        // $row BEFORE the form renders (this runs after the POST branch, so a
        // failed save still re-renders with them). Two wins: $field('other_col')
        // can read a value already prepared for this form — URL / master–detail
        // prefill, beforeAddLoad, or an earlier field's resolved default — and
        // the values become available to every later expression in design order.
        if ($mode === 'add') {
            $defLines = '';
            foreach ($designFields as $df) {
                $fn = (string) ($df['field'] ?? '');
                $raw = trim((string) ($df['defaultValue'] ?? ''));
                if ($fn === '' || $raw === '' || $fn === $pkName) continue;
                if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $fn)) continue;
                $expr = $this->compileDefaultExpr($raw);
                if ($expr === null || str_contains($expr, '?>')) continue;
                $defLines .= "\$row['$fn'] = \$row['$fn'] ?? ($expr);\n";
            }
            if ($defLines !== '') {
                $phpLogic .= "\n// Default values, resolved in design order so they can reference each other.\n"
                    . "\$field = static function (string \$__k, \$__d = '') use (&\$row) { return \$row[\$__k] ?? \$__d; };\n"
                    . $defLines;
            }
        }

        // Replace TABLE + prefill-whitelist placeholders
        $phpLogic = str_replace('TABLE', $tableName, $phpLogic);
        $prefillable = array_values(array_filter(array_map(static fn ($f) => (string) $f['name'], $fields), static fn ($n) => $n !== $pkName));
        $phpLogic = str_replace('PP_PREFILL_FIELDS', '[' . implode(', ', array_map(static fn ($n) => var_export($n, true), $prefillable)) . ']', $phpLogic);

        // Record Locking (edit only): a notice when someone else holds the row, plus
        // a heartbeat that keeps our own lock alive and releases it when we leave.
        // Both no-op unless Misc → Advanced enabled locking (RecordLock::acquire above
        // leaves $__lockedBy empty and PP_LOCK_ENABLED gates the client work).
        $lockBannerHtml = '';
        $lockScript = '';
        if ($mode === 'edit') {
            $lockBannerHtml = '<?php if (!empty($__lockedBy)): ?><div class="alert alert-warning d-flex align-items-center gap-2" role="alert"><i class="bi bi-lock-fill"></i><div><strong>This record is being edited by <?= htmlspecialchars($__lockedBy) ?>.</strong> It is read-only until they finish &mdash; your changes will not be saved.</div></div><?php endif; ?>';
            $lockScript = <<<PHP
<?php if (PP_LOCK_ENABLED): ?>
<script>
(function () {
    var LOCKED = <?= !empty(\$__lockedBy) ? 'true' : 'false' ?>;
    var API = '<?= APP_BASE ?>/api/lock.php', TABLE = '$tableName', ID = <?= (int) \$id ?>;
    var form = document.getElementById('ppMainForm');
    if (LOCKED) { // read-only: block every control on the held record
        if (form) form.querySelectorAll('input, select, textarea, button').forEach(function (el) { el.disabled = true; });
        document.querySelectorAll('.pp-form-actions button, .pp-form-actions [type=submit]').forEach(function (b) { b.disabled = true; });
        return;
    }
    function ping(action) {
        try {
            var body = 'table=' + encodeURIComponent(TABLE) + '&id=' + ID + (action ? '&action=' + action : '');
            if (action === 'release' && navigator.sendBeacon) { navigator.sendBeacon(API, new Blob([body], {type: 'application/x-www-form-urlencoded'})); return; }
            fetch(API, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body, keepalive: true });
        } catch (e) {}
    }
    var hb = setInterval(ping, <?= max(15, (int) (PP_LOCK_TIMEOUT / 2)) * 1000 ?>);
    window.addEventListener('pagehide', function () { clearInterval(hb); ping('release'); });
})();
</script>
<?php endif; ?>
PHP;
        }

        $pageContent = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
\PpPhp\Core\Auth\Permissions::requirePage('$mode', '$tableName');
$phpLogic
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>$titleHtml</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.css">$pluginCss<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css"></head>
<body data-pp-table="$tableName" data-pp-action="$mode"<?php if (!empty(\$_GET['pp_modal']) || !empty(\$_POST['pp_modal'])) echo ' class="pp-modal-mode"'; ?>>
<?php pp_shell_start(pp_page_title('$tableName', '$mode', '$titleAttr'), '$tableName', $crumbArg); ?>
<?= flashMessage() ?>
<?php if (\$error): ?><div class="alert alert-danger"><?= htmlspecialchars(\$error) ?></div><?php endif; ?>
$lockBannerHtml
$vsummaryHtml
$relMasterHtml
<form method="post" class="ppg-form" id="ppMainForm">
<?= csrfField() ?>
<?php if (!empty(\$_GET['pp_popup']) || !empty(\$_POST['pp_popup'])): ?><input type="hidden" name="pp_popup" value="1"><?php endif; ?>
<?php if (!empty(\$_GET['pp_modal']) || !empty(\$_POST['pp_modal'])): ?><input type="hidden" name="pp_modal" value="1"><?php endif; ?>
$bodyHtml
</form>
$relGridsHtml
$behaviourScript
$lockScript
<?php pp_shell_end(); ?>
<script>
/* Add-in-popup (iframe): hand the page title + content height to the modal, and
   make Cancel/Back close the popup instead of navigating inside the iframe. */
(function () {
    if (!document.body.classList.contains('pp-modal-mode')) return;
    function post(m) { try { parent.postMessage(m, '*'); } catch (e) {} }
    var h = document.querySelector('.pp-page-head h1');
    post({ ppFormModalTitle: h ? h.textContent.trim() : document.title });
    function sendH() { post({ ppFormModalHeight: Math.ceil(document.documentElement.scrollHeight) }); }
    sendH(); window.addEventListener('resize', sendH); window.addEventListener('load', sendH);
    setTimeout(sendH, 250); setTimeout(sendH, 700);
    // Track unsaved edits so the parent can warn before a casual close. The parent
    // reads window.ppFormDirty (same-origin) at close time.
    window.ppFormDirty = false;
    var _form = document.getElementById('ppMainForm');
    if (_form) ['input', 'change'].forEach(function (ev) { _form.addEventListener(ev, function () { window.ppFormDirty = true; }); });
    // Cancel/Back = explicit discard → close with no prompt.
    document.querySelectorAll('.pp-form-actions a').forEach(function (a) {
        a.addEventListener('click', function (e) { e.preventDefault(); post({ ppFormModal: 'close' }); });
    });
})();
</script>
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.js"></script>$pluginJs
<script>
// Modern themed date/time pickers (bundled flatpickr) on designed fields.
// Exposed so dynamically-revealed rows (inline add) can (re)initialise too.
window.ppInitPickers = function (root) {
    if (!window.flatpickr) return;
    (root || document).querySelectorAll('[data-pp-picker]').forEach(function (el) {
        if (el._flatpickr) return;   // already enhanced
        var kind = el.getAttribute('data-pp-picker');
        // Optional designer-chosen display format (YYYY-MM-DD…) → flatpickr tokens.
        var fmt = el.getAttribute('data-pp-format');
        var f2 = fmt ? fmt.replace(/YYYY/g, 'Y').replace(/MM/g, 'm').replace(/DD/g, 'd') : '';
        // Stored value stays MySQL-safe (Y-m-d / Y-m-d H:i:S) so DATE/DATETIME
        // columns accept it; the chosen format only changes what's DISPLAYED
        // (flatpickr's altInput). Setting the stored dateFormat to e.g. DD/MM/YYYY
        // would post '20/06/2026' → "Incorrect date value" on a DATE column.
        // TimePicker options (data-pp-*): default 24-hour; opt-in seconds / minute step.
        var t24 = el.getAttribute('data-pp-time24') !== '0';
        var secs = el.getAttribute('data-pp-seconds') === '1';
        var minInc = parseInt(el.getAttribute('data-pp-step'), 10);
        var tfmt = secs ? 'H:i:S' : 'H:i';
        var dfmt = kind === 'time' ? tfmt : (kind === 'datetime' ? ('Y-m-d ' + tfmt) : 'Y-m-d');
        var afmt = kind === 'datetime' ? 'M j, Y — H:i' : 'M j, Y';
        if (f2 && kind !== 'time') { afmt = kind === 'datetime' ? (f2 + ' — H:i') : f2; }
        var pkOpts = {
            dateFormat: dfmt,
            enableTime: kind !== 'date',
            noCalendar: kind === 'time',
            altInput: kind !== 'time',
            altInputClass: 'pp-input',
            altFormat: afmt,
            time_24hr: t24,
            enableSeconds: secs,
            allowInput: true,
            disableMobile: false
        };
        if (minInc > 0) pkOpts.minuteIncrement = minInc;
        var inst = flatpickr(el, pkOpts);
        // keep required-validation on the visible (alt) input
        if (el.required && inst.altInput) { inst.altInput.required = true; el.required = false; }
    });
};
window.ppInitPickers(document);
</script>
$depLookupsScript
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/js/events.js"></script>
$pgChrome</body></html>
PHP;

        $path = $dir . '/' . $mode . '.php';
        file_put_contents($path, $pageContent);
        $this->log("Wrote generated/$tableName/$mode.php");
        return [$path];
    }

    private function generateTableViewPage(string $outputDir, string $tableName, array $fields): array
    {
        $this->log("Generating view page for table: $tableName");
        $dir = $outputDir . '/generated/' . $tableName;
        if (!is_dir($dir)) mkdir($dir, 0755, true);

        $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));
        // The table's REAL primary key (id, std_id, customer_id…) so view-page
        // logic (title-field pick, date-format guard) never keys on a literal 'id'.
        $pkName = $this->primaryKeyFor($tableName);
        $pageTypes = $this->pageTypesFor($tableName);
        $viewEditButton = in_array('edit', $pageTypes, true)
            ? '<a href="<?= APP_BASE ?>/generated/' . $tableName . '/edit.php?id=<?= (int) $id ?>" class="ppd-grid-toolbar-btn"><i class="bi bi-pencil"></i> ' . '<?= pp_t(\'ui.edit\', \'Edit\') ?></a>'
            : '';
        $viewPrintButton = in_array('print', $pageTypes, true)
            ? '<a href="<?= APP_BASE ?>/generated/' . $tableName . '/print.php?id=<?= (int) $id ?>" target="_blank" class="ppd-grid-toolbar-btn"><i class="bi bi-printer"></i> <?= pp_t(\'ui.print\', \'Print\') ?></a>'
            : '';
        // PDF page-type: opens the dedicated PDF document page for this record,
        // where html2pdf.js generates a real, downloadable PDF.
        $viewPdfButton = in_array('pdf', $pageTypes, true)
            ? '<a href="<?= APP_BASE ?>/generated/' . $tableName . '/pdf.php?id=<?= (int) $id ?>" target="_blank" class="ppd-grid-toolbar-btn"><i class="bi bi-file-earmark-pdf"></i> <?= pp_t(\'ui.pdf\', \'PDF\') ?></a>'
            : '';

        // Prev / Next record navigation (Misc → per-table)
        $nextPrevPhp = '';
        $nextPrevHtml = '';
        if (!empty($this->miscFor($tableName)['nextPrevButtons'])) {
            $npPk = $this->primaryKeyFor($tableName);
            $nextPrevPhp = "\n\$__prevId = \PpPhp\Core\DB::FetchOne(\"SELECT `$npPk` FROM `$tableName` WHERE `$npPk` < ? ORDER BY `$npPk` DESC LIMIT 1\", [\$id]);\n"
                . "\$__nextId = \PpPhp\Core\DB::FetchOne(\"SELECT `$npPk` FROM `$tableName` WHERE `$npPk` > ? ORDER BY `$npPk` ASC LIMIT 1\", [\$id]);\n";
            $nextPrevHtml = '<?php if ($__prevId): ?><a href="view.php?id=<?= (int) $__prevId ?>" class="ppd-grid-toolbar-btn"><i class="bi bi-chevron-left"></i> Prev</a><?php endif; ?>'
                . "\n" . '<?php if ($__nextId): ?><a href="view.php?id=<?= (int) $__nextId ?>" class="ppd-grid-toolbar-btn">Next <i class="bi bi-chevron-right"></i></a><?php endif; ?>' . "\n";
        }

        $viewFields = '';
        // "Hide empty fields" (pages.json → settings.view.hideEmptyFields): wrap each
        // field row in a runtime guard that drops it when the record's raw value is
        // blank. Off = every field always renders (original behaviour).
        $hideEmptyView = !empty($this->viewSettingsFor($tableName)['hideEmptyFields']);
        $wrapEmpty = static function (string $fname, string $divHtml) use ($hideEmptyView): string {
            if (!$hideEmptyView) return $divHtml . "\n";
            $n = addslashes($fname);
            return "<?php if (trim((string)(\$row['$n'] ?? '')) !== ''): ?>" . $divHtml . "<?php endif; ?>\n";
        };
        $viewUsedControls = [];
        $formEditControls = $this->editControlsFor($tableName);
        $lookupFields = $this->lookupFieldsFor($tableName);
        $optionMaps = $this->optionMapsFor($tableName);
        // Record title field: prefer a name/title-like column, else the first
        // text-ish non-id column (drives the header title + avatar initials).
        $titleFieldName = '';
        $titleTextFallback = '';
        foreach ($fields as $f) {
            $fn = (string) ($f['name'] ?? '');
            if ($fn === $pkName || !($f['view'] ?? true)) continue;
            $ft = strtolower((string) ($f['type'] ?? 'text'));
            if ($titleFieldName === '' && in_array(strtolower($fn), ['name', 'title', 'label', 'subject', 'fullname', 'full_name'], true)) {
                $titleFieldName = $fn;
            }
            if ($titleTextFallback === '' && (str_contains($ft, 'char') || str_contains($ft, 'text') || in_array($ft, ['string', ''], true))) {
                $titleTextFallback = $fn;
            }
        }
        if ($titleFieldName === '') $titleFieldName = $titleTextFallback;
        foreach ($fields as $f) {
            if (!($f['view'] ?? true)) continue;
            $name = addslashes($f['name']);
            // Label from the Misc Label Editor (per-language), design label as fallback.
            $label = "<?= htmlspecialchars(pp_t('label.$tableName.$name', '" . addslashes((string) ($f['label'] ?? $f['name'])) . "')) ?>";
            $type = addslashes($f['type'] ?? 'text');
            $full = in_array($type, ['textarea', 'html', 'richtext', 'json'], true) ? ' pp-dl-full' : '';
            // Runner hook — read this field on a View page: getControl(name).getValue().
            $dpf = ' data-pp-field="' . htmlspecialchars((string) $f['name']) . '"';

            // Configured View As control: a PER-PAGE 'view' override wins, else the
            // designed grid column / field-level viewAs default.
            $ppView = $this->perPageControl($f, 'view');
            $viewAs = $ppView !== null ? $ppView['control'] : (string) ($f['viewControl'] ?? $f['viewAs'] ?? 'PlainText');
            $viewAsSet = $ppView !== null ? $ppView['settings'] : (array) ($f['viewSettings'] ?? $f['viewAsSettings'] ?? []);
            if ($viewAs !== '' && $viewAs !== 'PlainText') {
                $viewUsedControls[$viewAs] = true;
                $settingsExport = var_export($viewAsSet, true);
                $viewFields .= $wrapEmpty($f['name'], "<div class=\"$full\"$dpf><dt>$label</dt><dd><?= \\PpPhp\\Core\\Designer\\FieldControl\\FieldControlRuntime::render('view', '" . addslashes($viewAs) . "', '$name', \$row['$name'] ?? '', $settingsExport)['html'] ?></dd></div>");
                continue;
            }

            // Lookup Wizard field: show the lookup table's display value(s) —
            // CSV-aware for multi-value lookups. Skipped when the field is set to
            // "Show … value" (falls through to the raw value below).
            if (isset($lookupFields[$f['name']]) && ($lookupFields[$f['name']]['displayAs'] ?? 'label') !== 'value') {
                $lkExport = var_export(['table' => $lookupFields[$f['name']]['table'], 'key' => $lookupFields[$f['name']]['key'], 'display' => $lookupFields[$f['name']]['display']], true);
                $display = "<?= htmlspecialchars(pp_lookup_display_value($lkExport, (string) (\$row['$name'] ?? ''))) ?>";
                $viewFields .= $wrapEmpty($f['name'], "<div class=\"$full\"$dpf><dt>$label</dt><dd>$display</dd></div>");
                continue;
            }

            // Choice / toggle control with a static value→label map (Dropdown,
            // Radio, Switch, Checkbox, manual Lookup): show the friendly label.
            if (isset($optionMaps[$f['name']])) {
                $omExport = var_export($optionMaps[$f['name']], true);
                $display = "<?= htmlspecialchars(pp_option_display_value($omExport, (string) (\$row['$name'] ?? ''))) ?>";
                $viewFields .= $wrapEmpty($f['name'], "<div class=\"$full\"$dpf><dt>$label</dt><dd>$display</dd></div>");
                continue;
            }

            // Smart default: a typed image/file column, or a field whose EDIT
            // control is the File/Image uploader, renders through the matching
            // View control (gallery / file list) instead of showing raw JSON.
            $smartView = $this->typeViewControl((string) ($f['type'] ?? ''), (string) $f['name']);
            $smartSet = [];
            if ($smartView === '' && (($formEditControls[$f['name']]['editControl'] ?? '') === 'FileImage')) {
                $es = (array) ($formEditControls[$f['name']]['editSettings'] ?? []);
                $imgOnly = in_array(strtolower((string) ($es['imageOnly'] ?? '')), ['1', 'yes', 'true', 'on'], true);
                $smartView = $imgOnly ? 'Image' : 'File';
                if (!$imgOnly) $smartSet = ['showIcon' => 'yes', 'showSize' => 'yes', 'showThumb' => 'yes'];
            }
            if ($smartView !== '' && $smartView !== 'PlainText') {
                $viewUsedControls[$smartView] = true;
                $settingsExport = var_export($smartSet, true);
                $viewFields .= $wrapEmpty($f['name'], "<div class=\"$full\"$dpf><dt>$label</dt><dd><?= \\PpPhp\\Core\\Designer\\FieldControl\\FieldControlRuntime::render('view', '" . addslashes($smartView) . "', '$name', \$row['$name'] ?? '', $settingsExport)['html'] ?></dd></div>");
                continue;
            }

            $display = "<?= htmlspecialchars((string)(\$row['$name'] ?? '')) ?>";
            if ($type === 'boolean') $display = "<?= (\$row['$name'] ?? '') ? '<span class=\"badge bg-success\">Yes</span>' : '<span class=\"badge bg-secondary\">No</span>' ?>";
            elseif ($type === 'currency') $display = "<?= (\$row['$name'] ?? '') !== '' ? '\$' . number_format((float)(\$row['$name']), 2) : '' ?>";
            elseif ($type === 'date' && $f['name'] !== $pkName) $display = "<?= !empty(\$row['$name']) ? date('M j, Y', strtotime(\$row['$name'])) : '' ?>";
            elseif ($type === 'datetime') $display = "<?= !empty(\$row['$name']) ? date('M j, Y g:i a', strtotime(\$row['$name'])) : '' ?>";
            elseif ($type === 'textarea') $display = "<?= nl2br(htmlspecialchars((string)(\$row['$name'] ?? ''))) ?>";

            $viewFields .= $wrapEmpty($f['name'], "<div class=\"$full\"$dpf><dt>$label</dt><dd>$display</dd></div>");
        }
        // Record title/subtitle for the header (falls back to "Record #id").
        $titleExpr = $titleFieldName !== ''
            ? "trim((string) (\$row['" . addslashes($titleFieldName) . "'] ?? '')) !== '' ? (string) \$row['" . addslashes($titleFieldName) . "'] : ('Record #' . \$id)"
            : "'Record #' . \$id";

        // Designed extras (detail grids, KPIs, charts, headings…) from the
        // table's View design render below the field section. Fields/chrome
        // the page already emits itself are skipped.
        $viewDesign = $this->designFor($tableName, 'view');
        // Designer page title as the shell-title fallback (Misc → Page Titles still
        // overrides per-language); else the runtime table caption.
        $viewDesignTitle = str_replace(["\r", "\n"], ' ', trim((string) ($viewDesign['title'] ?? '')));
        $viewTitleFallback = $viewDesignTitle !== '' ? var_export($viewDesignTitle, true) : "pp_table_caption('$tableName', '$tableLabel')";
        $extraHtml = empty($viewDesign['components']) ? '' : $this->componentExtrasHtml(
            (array) $viewDesign['components'],
            ['field', 'form', 'buttons', 'breadcrumb', 'table-grid', 'list-toolbar', 'pagination', 'selection-bar', 'filter-chips', 'filter-panel', 'search']
        );
        // Tables module relations: auto detail sections (For Master Table →
        // View) and the parent-record card (For Details Table → View).
        $extraHtml .= $this->relationGridsHtml($tableName, 'view');
        $extraHtml .= $this->pageChromeHtml($viewDesign);   // theme/width/align + custom CSS/JS
        $masterCardHtml = $this->relationMasterCardHtml($tableName, 'view');
        $tableLabelSlashed = addslashes((string) $tableLabel);   // for the SEO default-title PHP string (A2)
        $publicReadLit = $this->publicReadFor($tableName) ? 'true' : 'false';   // A8
        // View-control assets (e.g. Image gallery / File preview → pp-lightbox).
        [$viewPluginCss, $viewPluginJs] = $this->pluginHeadAssets(array_keys($viewUsedControls));

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Auth\PageAccess::guard('view', '$tableName', $publicReadLit);
\$id = (int) (\$_GET['id'] ?? 0);
if (!\$id) { header('Location: ' . APP_BASE . '/generated/$tableName/list.php'); exit; }
\$__rls = \PpPhp\Core\Auth\RowSecurity::andSql('$tableName');
\$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$tableName` WHERE `" . pp_pk('$tableName') . "` = ?" . \$__rls, [\$id]);
if (empty(\$rows)) { \$_SESSION['_flash'] = ['message' => 'Record not found.', 'type' => 'error']; header('Location: ' . APP_BASE . '/generated/$tableName/list.php'); exit; }
\$row = \PpPhp\Core\Auth\FieldCrypto::decryptRow('$tableName', \$rows[0]);
if (!array_key_exists('id', \$row)) \$row['id'] = \$row[pp_pk('$tableName')] ?? null;
\PpPhp\Core\Events::fire('tables.$tableName.page.beforeViewLoad', ['record' => &\$row]);
\PpPhp\Core\Events::fire('tables.$tableName.page.processValues', ['record' => &\$row, 'action' => 'view']);
$nextPrevPhp
// Per-record SEO meta (feature A2). No-op unless this page opted in (config/seo-meta.php).
\$__seoTitle = htmlspecialchars('$tableLabelSlashed - View', ENT_QUOTES);
\$__seoMeta = '';
\$__seoAll = @include __DIR__ . '/../../config/seo-meta.php';
\$__seoCfg = is_array(\$__seoAll) ? (\$__seoAll['$tableName'] ?? []) : [];
if (\$__seoCfg) {
    \$__sch = (!empty(\$_SERVER['HTTPS']) && \$_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    \$__seoBase = isset(\$_SERVER['HTTP_HOST']) ? \$__sch . '://' . \$_SERVER['HTTP_HOST'] . APP_BASE : APP_BASE;
    \$__seoRes = \PpPhp\Core\Seo\MetaTags::build(\$__seoCfg, \$row, ['base' => \$__seoBase, 'id' => \$id, 'default_title' => '$tableLabelSlashed - View']);
    \$__seoTitle = \$__seoRes['title'];
    \$__seoMeta = \$__seoRes['tags'];
}
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= \$__seoTitle ?></title><?= \$__seoMeta ?><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/regular.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css">$viewPluginCss</head>
<body data-pp-table="$tableName" data-pp-action="view"<?php if (!empty(\$_GET['pp_modal'])) echo ' class="pp-modal-mode"'; ?>>
<?php pp_shell_start(pp_page_title('$tableName', 'view', $viewTitleFallback), '$tableName',
    '<nav class="pp-breadcrumb"><a href="' . APP_BASE . '/">' . htmlspecialchars(pp_t('ui.home', 'Home')) . '</a><i class="bi bi-chevron-right sep"></i><a href="' . APP_BASE . '/generated/$tableName/list.php">' . htmlspecialchars(pp_table_caption('$tableName', '$tableLabel')) . '</a><i class="bi bi-chevron-right sep"></i><span>' . pp_t('ui.view', 'View') . '</span></nav>'); ?>
<?php \$__rtitle = $titleExpr; ?>
<div class="pp-record">
$masterCardHtml
<div class="pp-record-head">
  <div class="pp-record-avatar"><?= htmlspecialchars(strtoupper(mb_substr(preg_replace('/[^\\p{L}\\p{N}]/u', '', \$__rtitle) ?: 'R', 0, 2))) ?></div>
  <div class="pp-record-titles">
    <div class="pp-record-title"><?= htmlspecialchars(\$__rtitle) ?></div>
    <div class="pp-record-sub">$tableLabel · <?= pp_t('ui.record', 'Record') ?> #<?= (int) \$id ?></div>
  </div>
  <div class="pp-record-actions pp-no-print">
$viewEditButton
$viewPrintButton
$viewPdfButton
<a href="<?= APP_BASE ?>/generated/$tableName/list.php" class="ppd-grid-toolbar-btn"><i class="bi bi-arrow-left"></i> <?= pp_t('ui.back_to_list', 'Back to List') ?></a>
$nextPrevHtml
  </div>
</div>
<div class="pp-record-card">
  <div class="pp-record-card-h"><i class="bi bi-card-list"></i> <?= pp_t('ui.details', 'Details') ?></div>
  <div class="pp-dl">
$viewFields
  </div>
</div>
$extraHtml
</div>
<?php pp_shell_end(); ?>
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/js/events.js"></script>$viewPluginJs
<script>
/* View-in-popup (iframe): sync title/height to the modal; Back closes it, Prev/Next
   stay in the modal, Edit (and other in-app links) navigate the parent window. */
(function () {
    if (!document.body.classList.contains('pp-modal-mode')) return;
    function post(m) { try { parent.postMessage(m, '*'); } catch (e) {} }
    post({ ppFormModalTitle: document.title });
    function sendH() { post({ ppFormModalHeight: Math.ceil(document.documentElement.scrollHeight) }); }
    sendH(); window.addEventListener('load', sendH); setTimeout(sendH, 250); setTimeout(sendH, 700);
    document.addEventListener('click', function (e) {
        var a = e.target.closest('a[href]'); if (!a || a.target === '_blank') return;
        var h = a.getAttribute('href') || ''; if (!h || h.charAt(0) === '#' || /^(javascript:|mailto:|tel:)/i.test(h)) return;
        if (/list\.php(\?|$)/.test(a.href)) { e.preventDefault(); post({ ppFormModal: 'close' }); return; }
        if (/view\.php(\?|$)/.test(a.href)) { e.preventDefault(); location.href = a.href + (a.href.indexOf('?') > -1 ? '&' : '?') + 'pp_modal=1'; return; }
        e.preventDefault(); post({ ppFormModal: 'navigate', url: a.href });
    });
})();
</script>
</body></html>
PHP;

        $path = $dir . '/view.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/view.php");
        return [$path];
    }

    /**
     * Should the generated app ship the notification bell + api/notifications.php
     * + notif table? True when the owner enabled Notifications (Misc → Advanced)
     * OR when an enabled Calendar produces in-app notifications (its "In-app
     * notifications" setting) — otherwise the calendar would write notification
     * rows that no bell/endpoint could display.
     */
    private function notifBellNeeded(): bool
    {
        if (!empty($this->ctx['advanced']['notifEnabled'])) return true;
        if (!empty($this->ctx['notifRules'])) return true;   // Notification Rules produce in-app notifications
        $c = $this->calendarSettings();
        return !empty($c['enabled']) && !empty($c['appReminders']);
    }

    /** Project-level calendar settings (Misc/Calendar console; console phase). */
    private function calendarSettings(): array
    {
        $c = (array) ($this->ctx['calendar'] ?? []);

        // The Designer → Common Pages → Calendar page is the customization
        // surface: its `calendar-app` component props override the raw
        // ModuleState config for look & behaviour (the Designer scaffold is
        // seeded from that same config, so it starts in sync). The ENABLE switch
        // stays the Tables "Create Calendar" toggle (ModuleState) so a designed
        // page never silently turns the calendar on for a whole project.
        $design = $this->commonDesignFor('calendar');
        $d = $design ? ($this->componentProps($design)['calendar-app'] ?? []) : [];
        $pick = static fn(string $k, $fallback) => array_key_exists($k, $d) ? $d[$k] : ($c[$k] ?? $fallback);

        $hex = static fn(string $v): string => preg_match('/^#[0-9a-fA-F]{3,8}$/', $v) ? $v : '';
        // CSS-safe: font-family / size values are injected into inline style + a
        // scoped <style>, so strip anything that could break out of the property.
        $cssSafe = static fn(string $v): string => trim(preg_replace('/[<>"\'{};]/', '', $v));
        // Icon classes are rendered into class attributes — allow only class chars.
        $iconSafe = static fn(string $v): string => trim(preg_replace('/[^a-zA-Z0-9 _-]/', '', $v));
        // One button's honored style bundle (label / icon / colours / font / size).
        $btnCfg = static fn(string $label, string $icon, string $bg, string $color, string $font, string $size): array => [
            'label' => $label,
            'icon'  => $iconSafe($icon) ?: 'bi-plus-lg',
            'bg'    => $hex(trim($bg)),
            'color' => $hex(trim($color)),
            'font'  => $cssSafe($font),
            'size'  => $cssSafe($size),
        ];
        $view = $pick('defaultView', 'month');
        $view = in_array($view, ['month', 'week', 'day', 'agenda', 'year'], true) ? $view : 'month';

        // Toolbar region: which views are offered (Month is always on). Guard the
        // default view so it can never point at a disabled view.
        $views = ['month'];
        if ((bool) $pick('viewWeek', true))   $views[] = 'week';
        if ((bool) $pick('viewDay', true))    $views[] = 'day';
        if ((bool) $pick('viewAgenda', true)) $views[] = 'agenda';
        if ((bool) $pick('viewYear', true))   $views[] = 'year';
        if (!in_array($view, $views, true)) $view = 'month';

        return [
            'enabled'        => !empty($c['enabled']),
            'allowPersonal'  => (bool) $pick('allowPersonal', true),
            'appReminders'   => (bool) $pick('appReminders', true),
            'emailReminders' => (bool) $pick('emailReminders', false),
            'defaultView'    => $view,
            'firstDay'       => ((int) $pick('firstDay', 0)) === 1 ? 1 : 0,
            'menuLabel'      => (string) ($pick('menuLabel', 'Calendar') ?: 'Calendar'),
            // Header region
            'heading'        => (string) ($d['heading'] ?? 'Calendar'),
            'subtitle'       => (string) ($d['subtitle'] ?? ''),
            'headerAlign'    => (($d['headerAlign'] ?? 'left') === 'center') ? 'center' : 'left',
            'showNewEvent'   => (bool) $pick('showNewEvent', true),
            'newEventLabel'  => (string) (($d['newEventLabel'] ?? 'New event') ?: 'New event'),
            'accent'         => $hex(trim((string) ($d['accent'] ?? ''))),
            'headingFont'    => $cssSafe((string) ($d['headingFont'] ?? '')),
            'headingSize'    => $cssSafe((string) ($d['headingSize'] ?? '')),
            'headingColor'   => $hex(trim((string) ($d['headingColor'] ?? ''))),
            'baseFont'       => $cssSafe((string) ($d['baseFont'] ?? '')),
            // Toolbar region
            'views'          => $views,
            'showToday'      => (bool) $pick('showToday', true),
            'showNav'        => (bool) $pick('showNav', true),
            'showIcs'        => (bool) $pick('showIcs', true),
            // Sidebar region
            'showSidebar'    => (bool) $pick('showSidebar', true),
            'sideCreate'     => (bool) $pick('sideCreate', true),
            'sideMiniMonth'  => (bool) $pick('sideMiniMonth', true),
            'sideCalendars'  => (bool) $pick('sideCalendars', true),
            'sideCategories' => (bool) $pick('sideCategories', true),
            'sideUpcoming'   => (bool) $pick('sideUpcoming', true),
            // Grid region
            'weekendShading' => (bool) $pick('weekendShading', false),
            'todayColor'     => $hex(trim((string) ($d['todayColor'] ?? ''))),
            'gridLine'       => $hex(trim((string) ($d['gridLine'] ?? ''))),
            // Footer region
            'showFooter'     => (bool) $pick('showFooter', false),
            'footerText'     => trim((string) ($d['footerText'] ?? '')),
            // Per-button customization (New event / Create)
            'newBtn'         => $btnCfg(
                (string) ($pick('newEventLabel', 'New event') ?: 'New event'),
                (string) ($d['newIcon'] ?? 'bi-plus-lg'),
                (string) ($d['newBg'] ?? ''), (string) ($d['newColor'] ?? ''),
                (string) ($d['newFont'] ?? ''), (string) ($d['newSize'] ?? '')
            ),
            'createBtn'      => $btnCfg(
                (string) ($pick('createLabel', 'Create') ?: 'Create'),
                (string) ($d['createIcon'] ?? 'bi-plus-lg'),
                (string) ($d['createBg'] ?? ''), (string) ($d['createColor'] ?? ''),
                (string) ($d['createFont'] ?? ''), (string) ($d['createSize'] ?? '')
            ),
            // In-app notification delivery targets the same table the app's bell
            // reads (Misc → Advanced → Notifications).
            'notifTable'     => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($this->ctx['advanced']['notifTableName'] ?? 'notifications')) ?: 'notifications',
            // In-app delivery is available whenever the app has a notification bell
            // (Misc → Advanced) OR the calendar's own In-app notifications are on —
            // the build ships the bell + endpoint in either case (notifBellNeeded()).
            'notifEnabled'   => !empty($this->ctx['advanced']['notifEnabled']) || (bool) $pick('appReminders', true),
        ];
    }

    /**
     * generated/calendar/index.php — the standalone Calendar / event-management
     * app. A single file that is BOTH the JSON API (dispatched by ?action=) and
     * the page shell that mounts the calendar UI. All logic lives in
     * core/Calendar/* (copied by copyCore), so this file stays a thin bootstrap
     * and new capabilities ship without a rebuild. Self-provisions its tables.
     *
     * @return array<int,string>
     */
    private function generateCalendarApp(string $outputDir): array
    {
        $this->log('Generating standalone Calendar app…');
        $dir = $outputDir . '/generated/calendar';
        if (!is_dir($dir)) mkdir($dir, 0755, true);

        $settings = $this->calendarSettings();
        $settingsPhp = var_export($settings, true);

        $content = <<<'PHP'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

$__cal = __CAL_SETTINGS__;
if (empty($__cal['enabled'])) { header('Location: ' . APP_BASE . '/'); exit; }

use PpPhp\Core\Calendar\CalendarSchema;
use PpPhp\Core\Calendar\CalendarService;
use PpPhp\Core\Calendar\CalendarIcs;

CalendarSchema::ensure();
CalendarService::configure(['notifTable' => (string) ($__cal['notifTable'] ?? 'notifications')]);
$__ctx = CalendarService::contextFromApp();

$__action = (string) ($_GET['action'] ?? ($_POST['action'] ?? ''));
if ($__action !== '') {
    // Read a JSON body once (POSTs send JSON) and resolve the CSRF token from
    // the body or the X-CSRF-Token header.
    $__raw = file_get_contents('php://input') ?: '';
    $__in = json_decode($__raw, true);
    if (!is_array($__in)) $__in = $_POST;
    $__isWrite = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';

    if ($__action !== 'export') header('Content-Type: application/json');

    if ($__isWrite) {
        $__tok = (string) ($__in['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
        if (!\PpPhp\Core\Security::CSRF()->Check($__tok)) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'Invalid session token — reload the page.']);
            exit;
        }
    }

    $calIds = null;
    if (isset($_GET['calendars']) && $_GET['calendars'] !== '') {
        $calIds = array_filter(array_map('intval', explode(',', (string) $_GET['calendars'])));
    }

    try {
        switch ($__action) {
            case 'bootstrap':
                echo json_encode([
                    'success'   => true,
                    'calendars' => CalendarService::calendars($__ctx),
                    'me'        => $__ctx['uid'],
                    'settings'  => ['emailReminders' => !empty($__cal['emailReminders']), 'allowPersonal' => !empty($__cal['allowPersonal'])],
                ]);
                break;
            case 'events':
                echo json_encode(CalendarService::events($__ctx, (string) ($_GET['from'] ?? ''), (string) ($_GET['to'] ?? ''), $calIds));
                break;
            case 'calendars':
                echo json_encode(CalendarService::calendars($__ctx));
                break;
            case 'event':
                echo json_encode(CalendarService::getEvent($__ctx, (int) ($_GET['id'] ?? 0)) ?: ['error' => 'not found']);
                break;
            case 'create_event':
                echo json_encode(CalendarService::createEvent($__ctx, is_array($__in['event'] ?? null) ? $__in['event'] : $__in));
                break;
            case 'update_event':
                echo json_encode(CalendarService::updateEvent($__ctx, (int) ($__in['id'] ?? 0), is_array($__in['event'] ?? null) ? $__in['event'] : $__in));
                break;
            case 'reschedule':
                echo json_encode(CalendarService::reschedule($__ctx, (int) ($__in['id'] ?? 0), (string) ($__in['start'] ?? ''), ($__in['end'] ?? '') !== '' ? (string) $__in['end'] : null));
                break;
            case 'delete_event':
                echo json_encode(CalendarService::deleteEvent($__ctx, (int) ($__in['id'] ?? 0)));
                break;
            case 'create_calendar':
                // Honour the Designer's "Personal calendars" setting server-side,
                // not just by hiding the button.
                if (empty($__cal['allowPersonal'])) {
                    echo json_encode(['success' => false, 'message' => 'Personal calendars are disabled for this app.']);
                    break;
                }
                echo json_encode(CalendarService::createCalendar($__ctx, $__in));
                break;
            case 'rsvp':
                echo json_encode(CalendarService::setRsvp($__ctx, (int) ($__in['id'] ?? 0), (string) ($__in['response'] ?? '')));
                break;
            case 'principals':
                echo json_encode(['users' => pp_cal_users(), 'groups' => pp_cal_groups()]);
                break;
            case 'import':
                $__events = CalendarIcs::parse((string) ($__in['ics'] ?? ''));
                $__n = 0;
                foreach ($__events as $__e) { $__e['calendarId'] = (int) ($__in['calendarId'] ?? 0); if (CalendarService::createEvent($__ctx, $__e)['success'] ?? false) $__n++; }
                echo json_encode(['success' => true, 'imported' => $__n]);
                break;
            case 'export':
                $from = (string) ($_GET['from'] ?? date('Y-01-01'));
                $to   = (string) ($_GET['to'] ?? date('Y-12-31'));
                $rows = CalendarService::events($__ctx, $from, $to, $calIds);
                // Map occurrences back to ics-friendly rows.
                $ics = CalendarIcs::export(array_map(static function ($e) {
                    return ['id' => $e['id'], 'title' => $e['title'], 'location' => $e['location'] ?? '', 'starts_at' => $e['start'], 'ends_at' => $e['end'] ?? '', 'all_day' => !empty($e['allDay']) ? 1 : 0];
                }, $rows), 'My Calendar');
                header('Content-Type: text/calendar; charset=utf-8');
                header('Content-Disposition: attachment; filename="calendar.ics"');
                echo $ics;
                break;
            default:
                http_response_code(404);
                echo json_encode(['success' => false, 'error' => 'Unknown action']);
        }
    } catch (\Throwable $e) {
        http_response_code(500);
        echo json_encode(['success' => false, 'error' => 'Server error: ' . $e->getMessage()]);
    }
    exit;
}

// ── People pickers (id + display name only) for sharing / attendees ──
function pp_cal_users(): array
{
    try {
        $rows = \PpPhp\Core\DB::FetchAll("SELECT id, username FROM `users` ORDER BY username ASC LIMIT 1000") ?: [];
        return array_map(static fn ($r) => ['id' => (int) $r['id'], 'name' => (string) $r['username']], $rows);
    } catch (\Throwable $e) { return []; }
}
function pp_cal_groups(): array
{
    try {
        $rows = \PpPhp\Core\DB::FetchAll("SELECT id, name FROM `groups` ORDER BY name ASC LIMIT 500") ?: [];
        return array_map(static fn ($r) => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $rows);
    } catch (\Throwable $e) { return []; }
}

$__token = \PpPhp\Core\Security::CSRF()->Token();
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title><?= pp_t('ui.calendar', 'Calendar') ?></title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fonts/fonts.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/fontawesome.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/fontawesome/7.2/css/solid.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/material-symbols/material-symbols.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/ppcal/pp-calendar.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css"></head>
<?php
// Designer-driven chrome: heading / subtitle / accent (Common Pages → Calendar).
$__calHeading = (string) ($__cal['heading'] ?? '') !== '' ? (string) $__cal['heading'] : pp_t('ui.calendar', 'Calendar');
$__calSub = (string) ($__cal['subtitle'] ?? '');
$__calAccent = (string) ($__cal['accent'] ?? '');
$__calToday = (string) ($__cal['todayColor'] ?? '');
$__calLine = (string) ($__cal['gridLine'] ?? '');
$__calSubClass = (($__cal['headerAlign'] ?? 'left') === 'center') ? ' is-center' : '';
$__calBaseFont = (string) ($__cal['baseFont'] ?? '');
$__calVars = '';
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $__calAccent)) {
    $__calVars .= '--pp-primary:' . htmlspecialchars($__calAccent) . ';--pp-primary-text:#fff;--pp-primary-soft:color-mix(in srgb,' . htmlspecialchars($__calAccent) . ' 14%,transparent);--pp-primary-border:color-mix(in srgb,' . htmlspecialchars($__calAccent) . ' 45%,transparent);';
}
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $__calToday)) $__calVars .= '--ppcal-today:' . htmlspecialchars($__calToday) . ';';
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $__calLine)) $__calVars .= '--ppcal-line:' . htmlspecialchars($__calLine) . ';';
if ($__calBaseFont !== '') $__calVars .= 'font-family:' . htmlspecialchars($__calBaseFont) . ';';
$__calAppStyle = $__calVars !== '' ? ' style="' . $__calVars . '"' : '';
// Header typography (Designer → Header region) is scoped to this page's
// page-head H1 + subtitle. Values are CSS-sanitised at build time.
$__calHeadCss = '';
$__hFont = (string) ($__cal['headingFont'] ?? '');
$__hSize = (string) ($__cal['headingSize'] ?? '');
$__hColor = (string) ($__cal['headingColor'] ?? '');
if ($__hFont !== '')  $__calHeadCss .= 'font-family:' . htmlspecialchars($__hFont) . ';';
if ($__hSize !== '')  $__calHeadCss .= 'font-size:' . htmlspecialchars($__hSize) . ';';
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $__hColor)) $__calHeadCss .= 'color:' . htmlspecialchars($__hColor) . ';';
if ($__calSubClass !== '') $__calHeadCss .= 'text-align:center;';   // headerAlign = center
?>
<?php if ($__calHeadCss !== '') : ?><style>body[data-pp-action="calendar"] .pp-page-head h1{<?= $__calHeadCss ?>}</style><?php endif; ?>
<body data-pp-table="__calendar__" data-pp-action="calendar">
<?php pp_shell_start($__calHeading, '__calendar__', '<nav class="pp-breadcrumb"><a href="' . APP_BASE . '/">' . htmlspecialchars(pp_t('ui.home', 'Home')) . '</a><i class="bi bi-chevron-right sep"></i><span>' . htmlspecialchars($__calHeading) . '</span></nav>'); ?>
<?php if ($__calSub !== '') : ?><p class="pp-calapp-sub<?= $__calSubClass ?>"><?= htmlspecialchars($__calSub) ?></p><?php endif; ?>
<div id="pp-calendar-app" class="pp-calapp"<?= $__calAppStyle ?>></div>
<?php if (!empty($__cal['showFooter'])) : ?><p class="pp-calapp-foot"><?= htmlspecialchars((string) ($__cal['footerText'] ?? '') !== '' ? (string) $__cal['footerText'] : ('© ' . date('Y') . ' — All rights reserved.')) ?></p><?php endif; ?>
<?php pp_shell_end(); ?>
<script>window.PP_BASE = '<?= APP_BASE ?>'; window.PP_ENTITLE = <?= json_encode(function_exists('pp_entitlements') ? pp_entitlements() : ['ai'=>false,'email'=>false,'sms'=>false]) ?>;</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
<?= pp_proxy_script() ?>
<script src="<?= APP_BASE ?>/assets/vendor/flatpickr/flatpickr.min.js"></script>
<script src="<?= APP_BASE ?>/assets/vendor/ppcal/pp-calendar.js"></script>
<script>
PPCalendarApp.mount(document.getElementById('pp-calendar-app'), {
    base: '<?= APP_BASE ?>/generated/calendar/index.php',
    token: '<?= htmlspecialchars($__token) ?>',
    firstDay: <?= (int) $__cal['firstDay'] ?>,
    defaultView: '<?= htmlspecialchars($__cal['defaultView']) ?>',
    emailReminders: <?= !empty($__cal['emailReminders']) ? 'true' : 'false' ?>,
    appReminders: <?= !empty($__cal['appReminders']) ? 'true' : 'false' ?>,
    notifEnabled: <?= !empty($__cal['notifEnabled']) ? 'true' : 'false' ?>,
    allowPersonal: <?= !empty($__cal['allowPersonal']) ? 'true' : 'false' ?>,
    views: <?= json_encode(array_values((array) ($__cal['views'] ?? ['month', 'week', 'day', 'agenda', 'year']))) ?>,
    showToday: <?= !empty($__cal['showToday']) ? 'true' : 'false' ?>,
    showNav: <?= !empty($__cal['showNav']) ? 'true' : 'false' ?>,
    showNewEvent: <?= !empty($__cal['showNewEvent']) ? 'true' : 'false' ?>,
    newEventLabel: <?= json_encode((string) ($__cal['newEventLabel'] ?? 'New event')) ?>,
    weekendShading: <?= !empty($__cal['weekendShading']) ? 'true' : 'false' ?>,
    showIcs: <?= !empty($__cal['showIcs']) ? 'true' : 'false' ?>,
    showSidebar: <?= !empty($__cal['showSidebar']) ? 'true' : 'false' ?>,
    sideCreate: <?= !empty($__cal['sideCreate']) ? 'true' : 'false' ?>,
    sideMiniMonth: <?= !empty($__cal['sideMiniMonth']) ? 'true' : 'false' ?>,
    sideCalendars: <?= !empty($__cal['sideCalendars']) ? 'true' : 'false' ?>,
    sideCategories: <?= !empty($__cal['sideCategories']) ? 'true' : 'false' ?>,
    sideUpcoming: <?= !empty($__cal['sideUpcoming']) ? 'true' : 'false' ?>,
    newBtn: <?= json_encode((array) ($__cal['newBtn'] ?? [])) ?>,
    createBtn: <?= json_encode((array) ($__cal['createBtn'] ?? [])) ?>
});
</script>
</body></html>
PHP;

        $content = str_replace('__CAL_SETTINGS__', $settingsPhp, $content);
        $path = $dir . '/index.php';
        file_put_contents($path, $content);
        $this->log('Wrote generated/calendar/index.php');
        return [$path];
    }

    /**
     * import.php — CSV import wizard (upload → auto-map columns → preview →
     * commit). Insertable columns are whitelisted at BUILD time; the uploaded
     * file is stored under storage/imports with a random token and re-read on
     * commit (basename-guarded). Honors the detail table's Add gating.
     */
    /**
     * import.php — a THIN bootstrap over core/DataOps.
     *
     * The wizard itself (sources, options, mapping, preview, commit) lives in
     * ImportPage + ImportRunner + FormatRegistry, shipped by copyCore(). New
     * sources, readers or Pages-module options need no build change: they are
     * config passed straight through.
     */
    private function generateTableImportPage(string $outputDir, string $tableName, array $fields): array
    {
        $this->log("Generating import page for table: $tableName");
        $dir = $outputDir . '/generated/' . $tableName;
        if (!is_dir($dir)) mkdir($dir, 0755, true);
        $tableLabel = ucwords(str_replace(['-', '_'], ' ', $tableName));

        // Insertable fields → whitelist + labels. An AUTO_INCREMENT key is never
        // importable (the DB assigns it); a MANUAL key IS importable (the user
        // supplies it). Keyed on the real pk name, not literal 'id'.
        $impPk = $this->primaryKeyFor($tableName);
        $impPkAuto = $this->primaryKeyIsAutoFor($tableName);
        $impFields = [];
        foreach ($fields as $f) {
            if (($f['name'] ?? '') === $impPk && $impPkAuto) continue;
            $impFields[(string) $f['name']] = (string) ($f['label'] ?? $f['name']);
        }

        $design        = $this->designFor($tableName, 'import');
        $fieldsExport  = var_export($impFields, true);
        $readersExport = var_export(array_values($this->allowedImportFormats($tableName) ?: ['csv']), true);
        $optionsExport = var_export($this->importOptionsFor($tableName), true);
        $pgChrome      = $this->pageChromeHtml($design);
        $chromeExport  = var_export($pgChrome, true);
        // The designed component tree drives block ORDER, PRESENCE and LABELS on
        // the wizard (ImportPage falls back to its standard layout when empty),
        // so editing the Import page in the Designer now changes the real page.
        $designExport  = var_export($this->importDesignBlocks($design), true);
        // Raw hrefs, NOT pre-rendered <link> tags: those embed a short-echo tag
        // that would never execute inside a PHP string literal. ImportPage
        // resolves them against APP_BASE at render time.
        $cssExport     = var_export($this->pageStylesheetHrefs(), true);

        $content = <<<PHP
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

\PpPhp\Core\Auth\Permissions::requirePage('import', '$tableName');
\PpPhp\Core\DataOps\ImportPage::handle([
    'table'   => '$tableName',
    'fields'  => $fieldsExport,
    'readers' => $readersExport,
    'caption' => pp_table_caption('$tableName', '$tableLabel'),
    'chrome'  => $chromeExport,
    'css'     => $cssExport,
    'options' => $optionsExport,
    'blocks'  => $designExport,
]);
PHP;

        $path = $dir . '/import.php';
        file_put_contents($path, $content);
        $this->log("Wrote generated/$tableName/import.php (DataOps bootstrap)");
        return [$path];
    }


    /**
     * The in-app Admin Area (dynamic permissions mode).
     *
     * Ships an admin-only management screen inside the generated app plus the
     * API that backs it. The engine reads groups / group_permissions / user_groups
     * LIVE in dynamic mode, so anything saved here takes effect on the next
     * request with no rebuild — that is the whole point of dynamic mode.
     *
     * Everything is gated by Permissions::isAdmin(); a non-admin who reaches
     * either the page or the API is bounced. Written for every build (cheap,
     * self-contained); the navbar link only appears in dynamic mode for admins.
     */
    /**
     * The in-app Admin Area (dynamic permissions mode).
     *
     * Ships an admin-only management console inside the generated app plus the
     * API behind it. The engine reads groups / group_permissions / user_groups
     * LIVE in dynamic mode, so everything saved here applies on the next request
     * with no rebuild.
     *
     * SAFETY. Every request (page and API) is gated by Permissions::isAdmin().
     * Mutating API actions additionally require a valid CSRF token, enforce the
     * project's password policy on user creation, validate role/status against
     * a whitelist, never return password/secret columns, and refuse to remove
     * the last administrator. Everything is parameterised.
     */
    private function generateAdminArea(string $outputDir, array $tables, array $opts = []): array
    {
        $written = [];
        $adminDir = $outputDir . '/generated/admin';
        if (!is_dir($adminDir)) mkdir($adminDir, 0755, true);
        $apiDir = $outputDir . '/api';
        if (!is_dir($apiDir)) mkdir($apiDir, 0755, true);

        $tablesJson = json_encode(array_values(array_map('strval', array_keys($tables))));

        // ── Designer-driven chrome (heading, subtitle, tabs, accent, exit label) ──
        $heading   = trim((string) ($opts['heading'] ?? '')) ?: 'Admin Area';
        $subtitle  = trim((string) ($opts['subtitle'] ?? '')) ?: 'Manage users, groups and permissions — changes take effect immediately.';
        $exitLabel = trim((string) ($opts['exitLabel'] ?? '')) ?: 'Exit Admin Area';
        $accentRaw = trim((string) ($opts['accent'] ?? ''));
        $accent    = preg_match('/^#[0-9a-fA-F]{3,8}$/', $accentRaw) ? $accentRaw : '';
        $tabDefs = [
            'perms'  => ['key' => 'tabPermissions', 'icon' => 'bi-grid-3x3-gap',  'label' => 'Permissions', 'title' => 'Grant or revoke what each group can do to every table'],
            'users'  => ['key' => 'tabUsers',       'icon' => 'bi-people',        'label' => 'Users',       'title' => 'Create, edit and remove user accounts and their group membership'],
            'assign' => ['key' => 'tabAssign',      'icon' => 'bi-ui-checks-grid','label' => 'Assign',      'title' => 'Bulk-assign users to groups in one grid'],
            'groups' => ['key' => 'tabGroups',      'icon' => 'bi-collection',    'label' => 'Groups',      'title' => 'Create and manage the groups permissions are assigned to'],
            'guide'  => ['key' => 'tabGuide',       'icon' => 'bi-question-circle','label' => 'Guide',      'title' => 'How the Admin Area works'],
        ];
        $enabled = [];
        foreach ($tabDefs as $tab => $d) {
            if (($opts[$d['key']] ?? true) !== false) $enabled[$tab] = $d;
        }
        if (!$enabled) $enabled = ['perms' => $tabDefs['perms']];   // never zero tabs
        $defaultTab = (string) array_key_first($enabled);
        $tabsHtml = '';
        $firstTab = true;
        foreach ($enabled as $tab => $d) {
            $tabsHtml .= '<button class="ppa-tab' . ($firstTab ? ' is-active' : '') . '" data-tab="' . $tab
                . '" title="' . htmlspecialchars($d['title'], ENT_QUOTES) . '"><i class="bi ' . $d['icon'] . '"></i> '
                . htmlspecialchars($d['label'], ENT_QUOTES) . '</button>';
            $firstTab = false;
        }
        $accentStyle = $accent !== ''
            ? ' style="--pp-primary:' . $accent . ';--pp-primary-hover:' . $accent . ';--pp-focus:' . $accent . ';--pp-focus-ring:' . $accent . '55"'
            : '';

        // ── api/admin.php — one dispatching endpoint, admin- + CSRF-gated ──
        $api = <<<'ADMINAPI'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$DB = '\PpPhp\Core\DB';
$do = preg_replace('/[^a-z-]/', '', (string) ($_GET['do'] ?? $_POST['do'] ?? ''));

// Only administrators may read or change anything here.
if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    http_response_code(403);
    echo json_encode(['success' => false, 'error' => 'Administrator access required.']);
    exit;
}

$isWrite = ($_SERVER['REQUEST_METHOD'] === 'POST');
$body = $isWrite ? (json_decode((string) file_get_contents('php://input'), true) ?: $_POST) : [];

// CSRF: every state change must carry the token from the admin page. A GET
// read is safe (no side effect) and is exempt.
if ($isWrite) {
    $token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $body['_token'] ?? '');
    if (!\PpPhp\Core\Security::CSRF()->Check($token)) {
        http_response_code(419);
        echo json_encode(['success' => false, 'error' => 'Your session expired. Reload the page and try again.']);
        exit;
    }
}

$GOVERNED = __GOVERNED_TABLES__;

// The permission tables must exist for any of this to work; create if absent.
try {
    $DB::Exec('CREATE TABLE IF NOT EXISTS `groups` (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, description TEXT, is_admin_group TINYINT(1) DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $DB::Exec('CREATE TABLE IF NOT EXISTS `user_groups` (user_id INT NOT NULL, group_id INT NOT NULL, PRIMARY KEY (user_id, group_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $DB::Exec('CREATE TABLE IF NOT EXISTS `group_permissions` (id INT AUTO_INCREMENT PRIMARY KEY, group_id INT NOT NULL, table_name VARCHAR(100) NOT NULL, perm_add TINYINT DEFAULT 0, perm_edit TINYINT DEFAULT 0, perm_delete TINYINT DEFAULT 0, perm_view TINYINT DEFAULT 0, perm_export TINYINT DEFAULT 0, perm_import TINYINT DEFAULT 0, perm_admin TINYINT DEFAULT 0, UNIQUE KEY gt (group_id, table_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
} catch (\Throwable $e) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => 'Could not prepare permission tables: ' . $e->getMessage()]);
    exit;
}

// Valid roles = the standard tiers plus any already in use. Validated on write.
$pp_roles = function () use ($DB) {
    $roles = ['user', 'editor', 'manager', 'admin'];
    try {
        foreach ($DB::FetchAll("SELECT DISTINCT role AS r FROM `users` WHERE role IS NOT NULL AND role <> ''") ?: [] as $row) {
            $roles[] = (string) $row['r'];
        }
    } catch (\Throwable $e) {}
    return array_values(array_unique(array_filter($roles)));
};
// Status options = the users.status ENUM values, or the standard lifecycle set.
$pp_statuses = function () use ($DB) {
    try {
        $col = $DB::FetchAll("SHOW COLUMNS FROM `users` LIKE 'status'")[0] ?? null;
        $type = strtolower((string) ($col['Type'] ?? ''));
        if (str_starts_with($type, 'enum') && preg_match_all("/'([^']*)'/", $type, $m)) {
            return array_values(array_unique($m[1]));
        }
    } catch (\Throwable $e) {}
    return ['active', 'pending', 'inactive', 'banned'];
};

try {
    if ($do === 'data') {
        $groups = $DB::FetchAll('SELECT id, name, description, is_admin_group FROM `groups` ORDER BY id') ?: [];
        $perms  = $DB::FetchAll('SELECT group_id, table_name, perm_view, perm_add, perm_edit, perm_delete, perm_export, perm_import FROM `group_permissions`') ?: [];
        $users  = $DB::FetchAll('SELECT id, username, email, fullname, role, status FROM `users` ORDER BY id') ?: [];
        $ug     = $DB::FetchAll('SELECT user_id, group_id FROM `user_groups`') ?: [];
        echo json_encode([
            'success' => true,
            'tables' => $GOVERNED,
            'groups' => $groups,
            'permissions' => $perms,
            'users' => $users,
            'assignments' => $ug,
            'roles' => $pp_roles(),
            'statuses' => $pp_statuses(),
            'me' => (int) (\PpPhp\Core\Security::CurrentUserData()['id'] ?? 0),
        ]);
        exit;
    }

    if ($do === 'save-group') {
        $id = (int) ($body['id'] ?? 0);
        $name = trim((string) ($body['name'] ?? ''));
        $desc = trim((string) ($body['description'] ?? ''));
        $admin = !empty($body['is_admin_group']) ? 1 : 0;
        if ($name === '') { echo json_encode(['success' => false, 'error' => 'Group name is required.']); exit; }
        if ($id > 0) {
            $DB::Update('groups', ['name' => $name, 'description' => $desc, 'is_admin_group' => $admin], ['id' => $id]);
        } else {
            if ($DB::FetchOne('SELECT id FROM `groups` WHERE name = ?', [$name])) {
                echo json_encode(['success' => false, 'error' => 'A group with that name already exists.']); exit;
            }
            $DB::Insert('groups', ['name' => $name, 'description' => $desc, 'is_admin_group' => $admin]);
        }
        echo json_encode(['success' => true]);
        exit;
    }

    if ($do === 'delete-group') {
        $id = (int) ($body['id'] ?? 0);
        $g = $DB::FetchAll('SELECT is_admin_group FROM `groups` WHERE id = ?', [$id])[0] ?? null;
        if ($g === null) { echo json_encode(['success' => false, 'error' => 'Group not found.']); exit; }
        if (!empty($g['is_admin_group'])) { echo json_encode(['success' => false, 'error' => 'The administrator group cannot be deleted.']); exit; }
        $DB::Delete('group_permissions', ['group_id' => $id]);
        $DB::Delete('user_groups', ['group_id' => $id]);
        $DB::Delete('groups', ['id' => $id]);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($do === 'save-perms') {
        $rows = (array) ($body['permissions'] ?? []);
        foreach ($rows as $r) {
            $gid = (int) ($r['group_id'] ?? 0);
            $tbl = (string) ($r['table_name'] ?? '');
            if ($gid <= 0 || !in_array($tbl, $GOVERNED, true)) continue;
            $data = [
                'group_id' => $gid, 'table_name' => $tbl,
                'perm_view' => !empty($r['view']) ? 1 : 0,
                'perm_add' => !empty($r['add']) ? 1 : 0,
                'perm_edit' => !empty($r['edit']) ? 1 : 0,
                'perm_delete' => !empty($r['delete']) ? 1 : 0,
                'perm_export' => !empty($r['export']) ? 1 : 0,
                'perm_import' => !empty($r['import']) ? 1 : 0,
            ];
            $existing = $DB::FetchOne('SELECT id FROM `group_permissions` WHERE group_id = ? AND table_name = ?', [$gid, $tbl]);
            if ($existing) { $DB::Update('group_permissions', $data, ['id' => (int) $existing]); }
            else { $DB::Insert('group_permissions', $data); }
        }
        echo json_encode(['success' => true]);
        exit;
    }

    if ($do === 'assign') {
        $uid = (int) ($body['user_id'] ?? 0);
        $gids = array_values(array_unique(array_map('intval', (array) ($body['group_ids'] ?? []))));
        if ($uid <= 0) { echo json_encode(['success' => false, 'error' => 'User is required.']); exit; }
        $DB::Delete('user_groups', ['user_id' => $uid]);
        foreach ($gids as $gid) { if ($gid > 0) $DB::Insert('user_groups', ['user_id' => $uid, 'group_id' => $gid]); }
        echo json_encode(['success' => true]);
        exit;
    }

    // Bulk membership: apply only the CHANGED (user,group) pairs the matrix editor
    // computed — added and removed — in batched statements. Scales to thousands of
    // users × dozens of groups because the payload is just the diff, not the grid.
    if ($do === 'assign-bulk') {
        $norm = function ($rows) {
            $out = [];
            foreach ((array) $rows as $p) {
                $u = (int) ($p[0] ?? 0); $g = (int) ($p[1] ?? 0);
                if ($u > 0 && $g > 0) $out[$u . ':' . $g] = [$u, $g];
            }
            return array_values($out);
        };
        $add = $norm($body['add'] ?? []);
        $remove = $norm($body['remove'] ?? []);
        // Only touch pairs whose user and group both still exist.
        $validU = []; foreach ($DB::FetchAll('SELECT id FROM `users`') ?: [] as $r) { $validU[(int) $r['id']] = true; }
        $validG = []; foreach ($DB::FetchAll('SELECT id FROM `groups`') ?: [] as $r) { $validG[(int) $r['id']] = true; }
        $keep = function ($pairs) use ($validU, $validG) {
            return array_values(array_filter($pairs, function ($p) use ($validU, $validG) { return isset($validU[$p[0]]) && isset($validG[$p[1]]); }));
        };
        $add = $keep($add); $remove = $keep($remove);
        $applied = 0;
        foreach (array_chunk($remove, 400) as $chunk) {
            if (!$chunk) continue;
            $ph = implode(',', array_fill(0, count($chunk), '(?,?)'));
            $args = []; foreach ($chunk as $p) { $args[] = $p[0]; $args[] = $p[1]; }
            $DB::Query('DELETE FROM `user_groups` WHERE (user_id, group_id) IN (' . $ph . ')', $args);
            $applied += count($chunk);
        }
        foreach (array_chunk($add, 400) as $chunk) {
            if (!$chunk) continue;
            $ph = implode(',', array_fill(0, count($chunk), '(?,?)'));
            $args = []; foreach ($chunk as $p) { $args[] = $p[0]; $args[] = $p[1]; }
            $DB::Query('INSERT IGNORE INTO `user_groups` (user_id, group_id) VALUES ' . $ph, $args);
            $applied += count($chunk);
        }
        echo json_encode(['success' => true, 'applied' => $applied]);
        exit;
    }

    if ($do === 'save-user') {
        $id = (int) ($body['id'] ?? 0);
        $username = trim((string) ($body['username'] ?? ''));
        $email = trim((string) ($body['email'] ?? ''));
        $fullname = trim((string) ($body['fullname'] ?? ''));
        $password = (string) ($body['password'] ?? '');
        if ($username === '' || $email === '') { echo json_encode(['success' => false, 'error' => 'Username and email are required.']); exit; }
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success' => false, 'error' => 'Enter a valid email address.']); exit; }

        $roles = $pp_roles(); $statuses = $pp_statuses();
        $role = (string) ($body['role'] ?? '');
        if (!in_array($role, $roles, true)) $role = in_array('user', $roles, true) ? 'user' : ($roles[0] ?? 'user');
        $status = (string) ($body['status'] ?? '');
        if (!in_array($status, $statuses, true)) $status = in_array('active', $statuses, true) ? 'active' : ($statuses[0] ?? 'active');

        if ($password !== '') {
            $err = \PpPhp\Core\Auth\AuthGuard::passwordError($password, ['username' => $username, 'email' => $email, 'fullname' => $fullname]);
            if ($err !== '') { echo json_encode(['success' => false, 'error' => $err]); exit; }
        } elseif ($id === 0) {
            echo json_encode(['success' => false, 'error' => 'A password is required for a new user.']); exit;
        }

        // uniqueness
        $dupe = $id > 0
            ? $DB::FetchOne('SELECT id FROM `users` WHERE (username = ? OR email = ?) AND id <> ?', [$username, $email, $id])
            : $DB::FetchOne('SELECT id FROM `users` WHERE username = ? OR email = ?', [$username, $email]);
        if ($dupe) { echo json_encode(['success' => false, 'error' => 'That username or email is already taken.']); exit; }

        $data = ['username' => $username, 'email' => $email, 'fullname' => $fullname, 'role' => $role, 'status' => $status];
        if ($password !== '') $data['password'] = \PpPhp\Core\Security::HashPassword($password);
        if ($id > 0) { $DB::Update('users', $data, ['id' => $id]); }
        else { $DB::Insert('users', $data); }
        echo json_encode(['success' => true]);
        exit;
    }

    if ($do === 'delete-user') {
        $id = (int) ($body['id'] ?? 0);
        $me = (int) (\PpPhp\Core\Security::CurrentUserData()['id'] ?? 0);
        if ($id === $me) { echo json_encode(['success' => false, 'error' => 'You cannot delete your own account.']); exit; }
        $victim = $DB::FetchAll('SELECT role FROM `users` WHERE id = ?', [$id])[0] ?? null;
        if ($victim === null) { echo json_encode(['success' => false, 'error' => 'User not found.']); exit; }
        $role = strtolower((string) ($victim['role'] ?? ''));
        if (str_contains($role, 'admin') || in_array($role, ['owner', 'superadmin'], true)) {
            $admins = (int) $DB::FetchOne("SELECT COUNT(*) FROM `users` WHERE LOWER(role) LIKE '%admin%' OR LOWER(role) IN ('owner','superadmin')");
            if ($admins <= 1) { echo json_encode(['success' => false, 'error' => 'This is the only administrator — create another before deleting it.']); exit; }
        }
        $DB::Delete('users', ['id' => $id]);
        foreach (['user_groups', 'social_auth_accounts', 'account_activations', 'remember_tokens', 'password_resets', 'user_2fa'] as $t) {
            try { if ($DB::Query("SHOW TABLES LIKE '" . $t . "'")->rowCount() > 0) $DB::Delete($t, ['user_id' => $id]); } catch (\Throwable $e) {}
        }
        echo json_encode(['success' => true]);
        exit;
    }

    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Unknown action.']);
} catch (\Throwable $e) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
ADMINAPI;
        $api = str_replace('__GOVERNED_TABLES__', 'json_decode(' . var_export($tablesJson, true) . ', true)', $api);
        $written[] = $this->writeFile($apiDir . '/admin.php', $api);

        // ── generated/admin/index.php — the console, wrapped in the app shell ──
        $page = <<<'ADMINPAGE'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
// Admins only — the page bounces everyone else to the no-access screen.
if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    $_SESSION['_flash'] = ['message' => 'The Admin Area is available to administrators only.', 'type' => 'danger'];
    header('Location: ' . APP_BASE . '/generated/auth/no-access.php');
    exit;
}
$user = \PpPhp\Core\Security::CurrentUserData();
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>__PPA_TITLE_HTML__</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css"></head>
<body>
<?php pp_shell_start('__PPA_TITLE_PHP__', '', ''); ?>
<div class="ppa-wrap"__PPA_ACCENT__>
    <div class="ppa-head">
        <div class="ppa-head-copy">
            <p class="ppa-sub"><i class="bi bi-shield-lock"></i> __PPA_SUBTITLE__</p>
        </div>
        <a href="<?= APP_BASE ?>/" class="ppa-exit" title="Leave the Admin Area and return to the app"><i class="bi bi-box-arrow-left"></i> __PPA_EXITLABEL__</a>
    </div>
    <div class="ppa-tabs" role="tablist">__PPA_TABS__</div>
    <div id="ppaBody" class="ppa-body"><div class="ppa-loading"><span class="ppa-spin"></span> Loading…</div></div>
</div>
<style>
/* The theme defines --pp-muted (not --pp-text-muted); alias so every muted
   label in this page + its modals follows the selected theme, incl. dark. */
:root{--pp-text-muted:var(--pp-muted,#64748b)}
.ppa-wrap{width:100%;padding:0;position:relative}
.ppa-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:-2px 0 6px}
.ppa-head-copy{flex:1;min-width:200px}
.ppa-title{margin:0;font-size:21px;font-weight:800;display:flex;align-items:center;gap:9px;color:var(--pp-text,#0f172a)}
.ppa-sub{margin:0;color:var(--pp-text-muted,#64748b);font-size:13px}
.ppa-exit{position:absolute;top:-46px;right:0;display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:10px;font-size:13px;font-weight:700;text-decoration:none;
  color:var(--pp-danger,#dc2626);background:color-mix(in srgb,var(--pp-danger,#dc2626) 10%,transparent);border:1px solid color-mix(in srgb,var(--pp-danger,#dc2626) 30%,transparent);white-space:nowrap}
.ppa-exit:hover{background:color-mix(in srgb,var(--pp-danger,#dc2626) 18%,transparent);color:var(--pp-danger,#dc2626)}
.ppa-tabs{display:flex;gap:2px;border-bottom:1px solid var(--pp-border,#e2e8f0);margin-bottom:10px;flex-wrap:wrap}
.ppa-tab{border:0;background:transparent;padding:9px 15px;font-size:13px;font-weight:650;color:var(--pp-text-muted,#64748b);cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;gap:6px;align-items:center}
.ppa-tab.is-active{color:var(--pp-primary,#4f46e5);border-bottom-color:var(--pp-primary,#4f46e5)}
.ppa-loading{padding:40px;text-align:center;color:var(--pp-text-muted,#64748b)}
.ppa-spin{display:inline-block;width:15px;height:15px;border:2px solid var(--pp-border,#cbd5e1);border-top-color:var(--pp-primary,#4f46e5);border-radius:50%;animation:ppaspin .7s linear infinite;vertical-align:-2px;margin-right:5px}
@keyframes ppaspin{to{transform:rotate(360deg)}}
.ppa-panel{display:grid;grid-template-columns:minmax(0,210px) minmax(0,1fr);gap:14px;align-items:start}
.ppa-glist{display:flex;flex-direction:column;gap:5px;max-height:70vh;overflow-y:auto;padding-right:2px}
.ppa-gitem{display:flex;align-items:center;gap:6px;padding:8px 10px;border:1px solid var(--pp-border,#e2e8f0);border-radius:9px;background:var(--pp-surface,#fff);cursor:pointer;text-align:left;font-size:12.5px;font-weight:650;color:var(--pp-text,#0f172a)}
.ppa-gitem.is-active{border-color:var(--pp-primary,#4f46e5);box-shadow:0 0 0 3px color-mix(in srgb,var(--pp-primary,#4f46e5) 12%,transparent)}
.ppa-gitem-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ppa-badge{padding:1px 6px;border-radius:999px;font-size:8px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--pp-primary,#4f46e5);background:color-mix(in srgb,var(--pp-primary,#4f46e5) 14%,transparent)}
.ppa-card{border:1px solid var(--pp-border,#e2e8f0);border-radius:12px;background:var(--pp-surface,#fff);overflow:hidden;min-width:0}
.ppa-toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:9px 11px;border-bottom:1px solid var(--pp-border,#e2e8f0);background:var(--pp-surface-soft,#f8fafc)}
.ppa-toolbar input[type=text]{flex:1;min-width:130px;max-width:300px;padding:7px 10px 7px 30px;border:1px solid var(--pp-border,#dbe2ea);border-radius:8px;font-size:12.5px;background:var(--pp-input-bg,#fff) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Ccircle cx='7' cy='7' r='5'/%3E%3Cpath d='M11 11l3 3'/%3E%3C/svg%3E") no-repeat 9px center;color:var(--pp-text,#0f172a)}
.ppa-btn{border:1px solid var(--pp-border,#dbe2ea);background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border-radius:8px;padding:6px 11px;font-size:12px;font-weight:650;cursor:pointer;display:inline-flex;gap:5px;align-items:center;white-space:nowrap}
.ppa-btn:hover{background:var(--pp-surface-soft,#f1f5f9)}
.ppa-btn-primary{background:var(--pp-primary,#4f46e5);color:#fff;border-color:var(--pp-primary,#4f46e5)}
.ppa-btn-primary:hover{filter:brightness(1.06);background:var(--pp-primary,#4f46e5)}
.ppa-btn-danger{color:var(--pp-danger,#dc2626)}
.ppa-btn-icon{padding:5px 8px}
.ppa-mwrap{overflow:auto;max-height:64vh}
.ppa-matrix{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
.ppa-matrix th,.ppa-matrix td{border-bottom:1px solid var(--pp-border,#e2e8f0);padding:6px 10px}
.ppa-matrix thead th{position:sticky;top:0;z-index:3;background:var(--pp-surface-soft,#f1f5f9);color:var(--pp-text-muted,#64748b);font-size:9.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;text-align:center;white-space:nowrap}
.ppa-matrix thead th span{display:block;margin-bottom:3px}
.ppa-matrix th.ppa-th-name,.ppa-matrix td.ppa-tbl-cell{position:sticky;left:0;z-index:2;background:var(--pp-surface,#fff);text-align:left}
.ppa-matrix thead th.ppa-th-name{z-index:4;background:var(--pp-surface-soft,#f1f5f9)}
.ppa-tbl-cell{color:var(--pp-text,#0f172a);font-weight:600;white-space:nowrap}
.ppa-matrix tbody tr:nth-child(even) td{background:color-mix(in srgb,var(--pp-surface-soft,#f8fafc) 55%,var(--pp-surface,#fff))}
.ppa-matrix tbody tr:nth-child(even) td.ppa-tbl-cell{background:color-mix(in srgb,var(--pp-surface-soft,#f8fafc) 55%,var(--pp-surface,#fff))}
.ppa-matrix td.ppa-c{text-align:center}
.ppa-cb{width:16px;height:16px;cursor:pointer;accent-color:var(--pp-primary,#4f46e5)}
.ppa-foot{display:flex;gap:9px;align-items:center;padding:10px 12px;border-top:1px solid var(--pp-border,#e2e8f0)}
.ppa-note{margin-left:auto;color:var(--pp-text-muted,#64748b);font-size:11.5px}
.ppa-admin-note{padding:18px;color:var(--pp-text-muted,#64748b);font-size:12.5px;line-height:1.6}
.ppa-admin-note i{color:var(--pp-success,#16a34a)}
.ppa-table{width:100%;border-collapse:collapse;font-size:13px}
.ppa-table th,.ppa-table td{border-bottom:1px solid var(--pp-border,#e2e8f0);padding:9px 11px;text-align:left;vertical-align:middle}
.ppa-table th{background:var(--pp-surface-soft,#f8fafc);color:var(--pp-text-muted,#64748b);font-size:10px;font-weight:800;letter-spacing:.04em;text-transform:uppercase}
.ppa-table tbody tr:hover td{background:var(--pp-surface-soft,#f8fafc)}
.ppa-chip{display:inline-block;margin:1px 3px 1px 0;padding:2px 8px;border-radius:999px;background:var(--pp-surface-soft,#eef2f7);color:var(--pp-text-muted,#475569);font-size:11px}
.ppa-status{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:999px;font-size:10.5px;font-weight:700;text-transform:capitalize}
.ppa-status.is-active{color:var(--pp-success,#16a34a);background:color-mix(in srgb,var(--pp-success,#16a34a) 12%,transparent)}
.ppa-status.is-pending{color:var(--pp-warning,#d97706);background:color-mix(in srgb,var(--pp-warning,#d97706) 14%,transparent)}
.ppa-status.is-inactive{color:var(--pp-text-muted,#64748b);background:var(--pp-surface-soft,#eef2f7)}
.ppa-status.is-banned{color:var(--pp-danger,#dc2626);background:color-mix(in srgb,var(--pp-danger,#dc2626) 12%,transparent)}
.ppa-empty{padding:26px;text-align:center;color:var(--pp-text-muted,#94a3b8)}
.ppa-rowacts{white-space:nowrap;text-align:right}
.ppa-rowacts .ppa-btn{margin-left:4px}
.ppa-tblscroll{overflow-x:auto}
.ppa-fields{display:grid;grid-template-columns:1fr 1fr;gap:11px;text-align:left}
.ppa-fields .ppa-wide{grid-column:1/-1}
.ppa-fields label{display:grid;gap:4px;margin:0}
.ppa-fields span{font-size:10px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--pp-text-muted,#64748b)}
.ppa-fields span em{color:var(--pp-danger,#dc2626);font-style:normal}
.ppa-fields input,.ppa-fields select{width:100%;padding:9px 11px;border:1px solid var(--pp-input-border,#dbe2ea);border-radius:9px;font-size:13.5px;box-sizing:border-box;background:var(--pp-input-bg,#fff);color:var(--pp-input-text,#0f172a)}
/* ── Guide tab ── */
.ppa-guide{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.ppa-gcard{border:1px solid var(--pp-border,#e2e8f0);border-radius:12px;background:var(--pp-surface,#fff);padding:14px 16px}
.ppa-gcard h3{margin:0 0 6px;font-size:14px;font-weight:800;color:var(--pp-text,#0f172a);display:flex;align-items:center;gap:8px}
.ppa-gcard h3 i{color:var(--pp-primary,#4f46e5)}
.ppa-gcard p,.ppa-gcard li{margin:0 0 5px;font-size:12.5px;line-height:1.6;color:var(--pp-text-muted,#64748b)}
.ppa-gcard ul{margin:4px 0 0;padding-left:18px}
.ppa-gcard code{background:var(--pp-code-bg,#f1f5f9);color:var(--pp-code-text,#475569);padding:1px 5px;border-radius:5px;font-size:11.5px}
.ppa-gcard.ppa-gspan{grid-column:1/-1}
/* ── Assign (user × group bulk matrix) ── */
.ppa-mx-ps,.ppa-mx-bgsel{padding:6px 9px;border:1px solid var(--pp-input-border,#dbe2ea);border-radius:8px;background:var(--pp-input-bg,#fff);color:var(--pp-input-text,#0f172a);font-size:12px}
.ppa-mx-bulk{display:flex;gap:8px;align-items:center;padding:8px 11px;background:color-mix(in srgb,var(--pp-primary,#4f46e5) 9%,transparent);border-bottom:1px solid var(--pp-border,#e2e8f0);flex-wrap:wrap;font-size:12.5px;font-weight:650;color:var(--pp-text,#0f172a)}
.ppa-mx-pager{display:inline-flex;align-items:center;gap:6px}
.ppa-mx-wrap{max-height:72vh}
.ppa-mx{width:auto;min-width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.ppa-mx th,.ppa-mx td{border-bottom:1px solid var(--pp-border,#e2e8f0);padding:1px 5px}
.ppa-mx .ppa-cb{width:14px;height:14px}
.ppa-mx thead th{position:sticky;top:0;z-index:3;background:var(--pp-surface-soft,#f1f5f9);vertical-align:bottom;text-align:center;padding-top:5px;padding-bottom:5px}
.ppa-mx-sel{position:sticky;left:0;z-index:2;background:var(--pp-surface,#fff);width:30px;min-width:30px;text-align:center}
.ppa-mx thead th.ppa-mx-sel{z-index:4}
.ppa-mx-user{position:sticky;left:30px;z-index:2;background:var(--pp-surface,#fff);min-width:148px;max-width:240px;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ppa-mx thead th.ppa-mx-user{z-index:4;text-align:left}
.ppa-mx-user strong{color:var(--pp-text,#0f172a);font-weight:650}
.ppa-mx-sub{font-size:10.5px;color:var(--pp-text-muted,#64748b);font-weight:400;margin-left:6px}
.ppa-mx-col{text-align:center;white-space:nowrap;padding-left:4px;padding-right:4px}
.ppa-mx-colname{display:block;margin-bottom:3px;font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;color:var(--pp-text-muted,#64748b);max-width:90px;overflow:hidden;text-overflow:ellipsis}
.ppa-mx tbody tr:nth-child(even) td{background:color-mix(in srgb,var(--pp-surface-soft,#f8fafc) 55%,var(--pp-surface,#fff))}
.ppa-mx tbody tr:nth-child(even) td.ppa-mx-sel,.ppa-mx tbody tr:nth-child(even) td.ppa-mx-user{background:color-mix(in srgb,var(--pp-surface-soft,#f8fafc) 55%,var(--pp-surface,#fff))}
.ppa-mx tbody tr:hover td{background:var(--pp-row-hover,#f5f7fa)}
.ppa-mx tbody tr:hover td.ppa-mx-sel,.ppa-mx tbody tr:hover td.ppa-mx-user{background:var(--pp-row-hover,#f5f7fa)}
/* ── SweetAlert2 → adopt the live theme (light/dark, instantly) ── */
.swal2-popup{background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border:1px solid var(--pp-border,#e2e8f0);border-radius:14px;box-shadow:var(--pp-shadow-lg,0 18px 44px rgba(0,0,0,.2));font-family:var(--pp-font,inherit)}
.swal2-title{color:var(--pp-text,#0f172a);font-size:19px;font-weight:800}
.swal2-html-container{color:var(--pp-text-muted,#64748b)}
.swal2-popup .ppa-fields input,.swal2-popup .ppa-fields select,.swal2-input,.swal2-textarea{background:var(--pp-input-bg,#fff);color:var(--pp-input-text,#0f172a);border-color:var(--pp-input-border,#dbe2ea)}
.swal2-popup .ppa-fields input:focus,.swal2-popup .ppa-fields select:focus,.swal2-input:focus{outline:none;border-color:var(--pp-focus,#4f46e5);box-shadow:0 0 0 3px var(--pp-focus-ring,rgba(79,70,229,.3))}
.swal2-popup input[type=checkbox]{accent-color:var(--pp-primary,#4f46e5)}
.swal2-actions .swal2-confirm.swal2-styled{background:var(--pp-primary,#4f46e5);color:var(--pp-primary-text,#fff);box-shadow:none;border-radius:9px;font-weight:700}
.swal2-actions .swal2-cancel.swal2-styled{background:var(--pp-surface-soft,#eef2f7);color:var(--pp-text,#0f172a);box-shadow:none;border-radius:9px;font-weight:700}
.swal2-actions .swal2-styled:focus{box-shadow:0 0 0 3px var(--pp-focus-ring,rgba(79,70,229,.35))}
.swal2-validation-message{background:var(--pp-danger-soft,#fef2f2);color:var(--pp-danger-text,#b91c1c)}
.swal2-icon.swal2-warning{border-color:var(--pp-warning,#d97706);color:var(--pp-warning,#d97706)}
.swal2-icon.swal2-error{border-color:var(--pp-danger,#dc2626);color:var(--pp-danger,#dc2626)}
.swal2-icon.swal2-error [class^=swal2-x-mark-line]{background:var(--pp-danger,#dc2626)}
.swal2-icon.swal2-success{border-color:var(--pp-success,#16a34a)}
.swal2-icon.swal2-success [class^=swal2-success-line]{background:var(--pp-success,#16a34a)}
.swal2-close{color:var(--pp-text-muted,#94a3b8)}
.swal2-popup.swal2-toast{background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border:1px solid var(--pp-border,#e2e8f0);box-shadow:var(--pp-shadow-md,0 8px 24px rgba(0,0,0,.15))}
.swal2-popup.swal2-toast .swal2-title{color:var(--pp-text,#0f172a);font-size:13.5px}
@media(max-width:640px){.ppa-panel{grid-template-columns:minmax(0,1fr)}.ppa-glist{flex-direction:row;overflow-x:auto;max-height:none}.ppa-gitem{white-space:nowrap}.ppa-exit{position:static;top:auto;right:auto}.ppa-head{margin-top:0}}
@media(max-width:560px){.ppa-fields{grid-template-columns:1fr}}
</style>
<script>
(function(){
  var PP = <?= json_encode(rtrim(APP_BASE, '/')) ?>;
  var CSRF = <?= json_encode(\PpPhp\Core\Security::CSRF()->Token()) ?>;
  var api = PP + '/api/admin.php';
  var ACTIONS = [['view','List/View'],['add','Add'],['edit','Edit'],['delete','Delete'],['export','Export/Print'],['import','Import']];
  var esc = function(s){ return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); };
  var state = { tables:[], groups:[], perms:{}, users:[], assign:{}, roles:[], statuses:[], me:0, activeGid:null, tab:'__PPA_DEFAULTTAB__',
    member:{}, memberOrig:{}, mCols:null, mSel:{}, mPage:0, mPageSize:50, mSearch:'' };
  var body = document.getElementById('ppaBody');

  function toast(msg, ok){ if(window.Swal){ Swal.fire({toast:true,position:'top-end',timer:2400,showConfirmButton:false,icon:ok?'success':'error',title:msg}); } else { alert(msg); } }
  function get(){ return fetch(api+'?do=data').then(function(r){return r.json();}); }
  function post(doName, payload){ return fetch(api+'?do='+doName,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':CSRF},body:JSON.stringify(payload||{})}).then(function(r){return r.json();}); }

  function load(){
    get().then(function(d){
      if(!d.success){ body.innerHTML='<div class="ppa-empty">'+esc(d.error||'Failed to load.')+'</div>'; return; }
      state.tables=d.tables||[]; state.groups=d.groups||[]; state.roles=d.roles||['user']; state.statuses=d.statuses||['active']; state.me=+d.me||0;
      state.perms={}; (d.permissions||[]).forEach(function(p){ (state.perms[p.group_id]=state.perms[p.group_id]||{})[p.table_name]={view:+p.perm_view,add:+p.perm_add,edit:+p.perm_edit,'delete':+p.perm_delete,export:+p.perm_export,import:+p.perm_import}; });
      state.users=d.users||[]; state.assign={}; (d.assignments||[]).forEach(function(a){ (state.assign[a.user_id]=state.assign[a.user_id]||[]).push(+a.group_id); });
      // Membership model for the bulk matrix: {uid:{gid:1}} plus a pristine snapshot to diff against.
      state.member={}; state.memberOrig={};
      (d.assignments||[]).forEach(function(a){ var u=+a.user_id,g=+a.group_id; (state.member[u]=state.member[u]||{})[g]=1; (state.memberOrig[u]=state.memberOrig[u]||{})[g]=1; });
      state.mSel={}; state.mPage=0;
      if(!state.activeGid && state.groups.length) state.activeGid=state.groups[0].id;
      render();
    }).catch(function(){ body.innerHTML='<div class="ppa-empty">Network error.</div>'; });
  }

  document.querySelectorAll('.ppa-tab').forEach(function(t){ t.addEventListener('click',function(){
    document.querySelectorAll('.ppa-tab').forEach(function(x){x.classList.remove('is-active');}); t.classList.add('is-active');
    state.tab=t.dataset.tab; render();
  });});

  function cell(gid,tbl,key){ return !!(state.perms[gid]&&state.perms[gid][tbl]&&state.perms[gid][tbl][key]); }
  function setCell(gid,tbl,key,v){ (state.perms[gid]=state.perms[gid]||{}); (state.perms[gid][tbl]=state.perms[gid][tbl]||{})[key]=v?1:0; }

  function render(){ if(state.tab==='perms') return renderPerms(); if(state.tab==='groups') return renderGroups(); if(state.tab==='users') return renderUsers(); if(state.tab==='assign') return renderMembers(); if(state.tab==='guide') return renderGuide(); }

  // ── Assign tab: user × group bulk matrix ──────────────────────────────
  function mVisibleGroups(){ if(!state.mCols) return state.groups.slice(); var s={}; state.mCols.forEach(function(id){s[id]=1;}); return state.groups.filter(function(g){return s[g.id];}); }
  function mFilteredUsers(){ var q=(state.mSearch||'').toLowerCase().trim(); if(!q) return state.users; return state.users.filter(function(u){ return ((u.username||'')+' '+(u.email||'')+' '+(u.fullname||'')+' '+(u.role||'')).toLowerCase().indexOf(q)>=0; }); }
  function mHas(uid,gid){ return !!(state.member[uid]&&state.member[uid][gid]); }
  function mSet(uid,gid,v){ if(v){ (state.member[uid]=state.member[uid]||{})[gid]=1; } else if(state.member[uid]){ delete state.member[uid][gid]; } }
  function mDiff(){ var add=[],rem=[]; state.users.forEach(function(u){ var id=u.id, cur=state.member[id]||{}, orig=state.memberOrig[id]||{}, keys={}; Object.keys(cur).forEach(function(k){keys[k]=1;}); Object.keys(orig).forEach(function(k){keys[k]=1;}); Object.keys(keys).forEach(function(g){ var c=!!cur[g],o=!!orig[g]; if(c&&!o) add.push([+id,+g]); else if(!c&&o) rem.push([+id,+g]); }); }); return {add:add,remove:rem}; }
  function mDirty(){ var d=mDiff(); return d.add.length+d.remove.length; }
  function mPageSizeN(total){ return state.mPageSize==='all'? (total||1) : (+state.mPageSize||50); }

  function renderMembers(){
    if(!state.users.length){ body.innerHTML='<div class="ppa-empty">No users yet — add some in the Users tab.</div>'; return; }
    if(!state.groups.length){ body.innerHTML='<div class="ppa-empty">No groups yet — create some in the Groups tab.</div>'; return; }
    var groups=mVisibleGroups(), fu=mFilteredUsers(), total=fu.length;
    var ps=mPageSizeN(total), pages=Math.max(1,Math.ceil(total/ps));
    if(state.mPage>=pages) state.mPage=pages-1; if(state.mPage<0) state.mPage=0;
    var start=state.mPage*ps, pageUsers=fu.slice(start,start+ps), dirty=mDirty();
    var head='<thead><tr><th class="ppa-mx-sel"><input type="checkbox" class="ppa-cb" id="mSelAll" title="Select every user in the current filter"></th><th class="ppa-mx-user">User</th>'
      + groups.map(function(g){ return '<th class="ppa-mx-col" title="Tick to add every filtered user to '+esc(g.name)+', untick to remove"><span class="ppa-mx-colname">'+esc(g.name)+(+g.is_admin_group?' ★':'')+'</span><input type="checkbox" class="ppa-cb ppa-mx-colcb" data-gid="'+g.id+'"></th>'; }).join('')
      +'</tr></thead>';
    var rows=pageUsers.map(function(u){
      return '<tr data-uid="'+u.id+'"><td class="ppa-mx-sel"><input type="checkbox" class="ppa-cb ppa-mx-rowsel" data-uid="'+u.id+'" '+(state.mSel[u.id]?'checked':'')+' title="Select this user for a bulk action"></td>'
        +'<td class="ppa-mx-user"><strong>'+esc(u.username)+'</strong>'+(u.fullname?'<span class="ppa-mx-sub">'+esc(u.fullname)+'</span>':'')+'</td>'
        + groups.map(function(g){ return '<td class="ppa-c"><input type="checkbox" class="ppa-cb ppa-mx-cell" data-uid="'+u.id+'" data-gid="'+g.id+'" '+(mHas(u.id,g.id)?'checked':'')+' title="'+esc(u.username)+' in '+esc(g.name)+'"></td>'; }).join('')
        +'</tr>';
    }).join('');
    var matrix='<div class="ppa-mwrap ppa-mx-wrap"><table class="ppa-matrix ppa-mx">'+head+'<tbody>'+(rows||'<tr><td colspan="'+(groups.length+2)+'" class="ppa-empty">No users match your search.</td></tr>')+'</tbody></table></div>';
    var pager='<div class="ppa-mx-pager"><button class="ppa-btn ppa-btn-icon" id="mPrev" '+(state.mPage<=0?'disabled':'')+' title="Previous page"><i class="bi bi-chevron-left"></i></button>'
      +'<span class="ppa-note">'+(total?(start+1):0)+'–'+Math.min(start+ps,total)+' of '+total+'</span>'
      +'<button class="ppa-btn ppa-btn-icon" id="mNext" '+(state.mPage>=pages-1?'disabled':'')+' title="Next page"><i class="bi bi-chevron-right"></i></button></div>';
    body.innerHTML='<div class="ppa-card"><div class="ppa-toolbar">'
      +'<input type="text" id="mSearch" placeholder="Search users…" value="'+esc(state.mSearch)+'" title="Filter the user rows by name, email or role">'
      +'<button class="ppa-btn" id="mCols" title="Choose which group columns are shown"><i class="bi bi-layout-three-columns"></i> Groups ('+groups.length+'/'+state.groups.length+')</button>'
      +'<select id="mPageSize" class="ppa-mx-ps" title="Rows per page"><option value="25"'+(state.mPageSize==25?' selected':'')+'>25 / page</option><option value="50"'+(state.mPageSize==50?' selected':'')+'>50 / page</option><option value="100"'+(state.mPageSize==100?' selected':'')+'>100 / page</option><option value="all"'+(state.mPageSize==='all'?' selected':'')+'>All</option></select>'
      + pager
      +'<button class="ppa-btn ppa-btn-primary" id="mSave" '+(dirty?'':'disabled')+' title="Save every membership change in one request"><i class="bi bi-save"></i> Save'+(dirty?' ('+dirty+')':'')+'</button>'
      +'<button class="ppa-btn" id="mReset" '+(dirty?'':'disabled')+' title="Discard unsaved changes">Reset</button>'
      +'</div><div id="mBulkWrap"></div>'+matrix+'</div>';
    // wiring
    var sr=document.getElementById('mSearch'); sr.addEventListener('input',function(){ state.mSearch=sr.value; state.mPage=0; renderMembers(); var el=document.getElementById('mSearch'); if(el){el.focus(); el.setSelectionRange(el.value.length,el.value.length);} });
    document.getElementById('mPageSize').addEventListener('change',function(){ state.mPageSize=this.value==='all'?'all':+this.value; state.mPage=0; renderMembers(); });
    var pv=document.getElementById('mPrev'); if(pv) pv.addEventListener('click',function(){ state.mPage--; renderMembers(); });
    var nx=document.getElementById('mNext'); if(nx) nx.addEventListener('click',function(){ state.mPage++; renderMembers(); });
    document.getElementById('mCols').addEventListener('click',mColsModal);
    document.getElementById('mSave').addEventListener('click',mSaveMembers);
    document.getElementById('mReset').addEventListener('click',function(){ state.member=JSON.parse(JSON.stringify(state.memberOrig)); renderMembers(); });
    var sa=document.getElementById('mSelAll'); mSyncSelAll(); sa.addEventListener('change',function(){ mFilteredUsers().forEach(function(u){ if(sa.checked) state.mSel[u.id]=1; else delete state.mSel[u.id]; }); renderMembers(); });
    document.querySelectorAll('.ppa-mx-colcb').forEach(function(cb){ mSyncCol(+cb.dataset.gid); cb.addEventListener('change',function(){ var gid=+cb.dataset.gid; mFilteredUsers().forEach(function(u){ mSet(u.id,gid,cb.checked); }); renderMembers(); }); });
    document.querySelectorAll('.ppa-mx-cell').forEach(function(cb){ cb.addEventListener('change',function(){ mSet(+cb.dataset.uid,+cb.dataset.gid,cb.checked); mSyncCol(+cb.dataset.gid); mRefreshDirty(); }); });
    document.querySelectorAll('.ppa-mx-rowsel').forEach(function(cb){ cb.addEventListener('change',function(){ if(cb.checked) state.mSel[+cb.dataset.uid]=1; else delete state.mSel[+cb.dataset.uid]; mRefreshBulk(); mSyncSelAll(); }); });
    mRefreshBulk();
  }
  function mSyncCol(gid){ var h=document.querySelector('.ppa-mx-colcb[data-gid="'+gid+'"]'); if(!h)return; var fu=mFilteredUsers(),on=0; fu.forEach(function(u){ if(mHas(u.id,gid)) on++; }); h.checked=fu.length>0&&on===fu.length; h.indeterminate=on>0&&on<fu.length; }
  function mSyncSelAll(){ var h=document.getElementById('mSelAll'); if(!h)return; var fu=mFilteredUsers(),on=0; fu.forEach(function(u){ if(state.mSel[u.id]) on++; }); h.checked=fu.length>0&&on===fu.length; h.indeterminate=on>0&&on<fu.length; }
  function mRefreshDirty(){ var d=mDirty(); var s=document.getElementById('mSave'),r=document.getElementById('mReset'); if(s){ s.disabled=!d; s.innerHTML='<i class="bi bi-save"></i> Save'+(d?' ('+d+')':''); } if(r) r.disabled=!d; }
  function mSelIds(){ return Object.keys(state.mSel).filter(function(k){return state.mSel[k];}).map(Number); }
  function mRefreshBulk(){ var wrap=document.getElementById('mBulkWrap'); if(!wrap)return; var ids=mSelIds();
    if(!ids.length){ wrap.innerHTML=''; return; }
    wrap.innerHTML='<div class="ppa-mx-bulk"><span>'+ids.length+' user'+(ids.length>1?'s':'')+' selected</span>'
      +'<select id="mBulkGroup" class="ppa-mx-bgsel" title="Choose the group to apply">'+state.groups.map(function(g){return '<option value="'+g.id+'">'+esc(g.name)+'</option>';}).join('')+'</select>'
      +'<button class="ppa-btn" id="mBulkAdd" title="Add the selected users to this group"><i class="bi bi-plus-lg"></i> Add to group</button>'
      +'<button class="ppa-btn ppa-btn-danger" id="mBulkRemove" title="Remove the selected users from this group"><i class="bi bi-dash-lg"></i> Remove</button>'
      +'<button class="ppa-btn" id="mBulkClear" title="Clear the selection">Clear</button></div>';
    var apply=function(v){ var gid=+document.getElementById('mBulkGroup').value; mSelIds().forEach(function(uid){ mSet(uid,gid,v); }); renderMembers(); };
    document.getElementById('mBulkAdd').addEventListener('click',function(){ apply(true); });
    document.getElementById('mBulkRemove').addEventListener('click',function(){ apply(false); });
    document.getElementById('mBulkClear').addEventListener('click',function(){ state.mSel={}; renderMembers(); });
  }
  function mColsModal(){
    var html='<div class="ppa-toolbar" style="border:0;padding:0 0 8px"><input type="text" id="mcSearch" placeholder="Filter groups…" style="flex:1"><button type="button" class="ppa-btn" id="mcAll">All</button><button type="button" class="ppa-btn" id="mcNone">None</button></div>'
      +'<div id="mcList" style="text-align:left;max-height:46vh;overflow:auto">'+state.groups.map(function(g){ var on=!state.mCols||state.mCols.indexOf(g.id)>=0; return '<label class="mc-row" data-name="'+esc(g.name.toLowerCase())+'" style="display:flex;gap:8px;align-items:center;padding:5px 2px"><input type="checkbox" value="'+g.id+'" '+(on?'checked':'')+'> '+esc(g.name)+(+g.is_admin_group?' <span class="ppa-badge">admin</span>':'')+'</label>'; }).join('')+'</div>';
    Swal.fire({title:'Show group columns',html:html,width:460,showCancelButton:true,confirmButtonText:'Apply',focusConfirm:false,didOpen:function(){
        var s=document.getElementById('mcSearch'); s.addEventListener('input',function(){ var q=s.value.toLowerCase(); document.querySelectorAll('#mcList .mc-row').forEach(function(r){ r.style.display=r.dataset.name.indexOf(q)>=0?'':'none'; }); });
        document.getElementById('mcAll').addEventListener('click',function(){ document.querySelectorAll('#mcList input').forEach(function(c){ if(c.closest('.mc-row').style.display!=='none') c.checked=true; }); });
        document.getElementById('mcNone').addEventListener('click',function(){ document.querySelectorAll('#mcList input').forEach(function(c){ if(c.closest('.mc-row').style.display!=='none') c.checked=false; }); });
      },
      preConfirm:function(){ return [].slice.call(document.querySelectorAll('#mcList input:checked')).map(function(c){return +c.value;}); }
    }).then(function(res){ if(!res.isConfirmed)return; state.mCols=(res.value&&res.value.length===state.groups.length)?null:res.value; state.mPage=0; renderMembers(); });
  }
  function mSaveMembers(){
    var d=mDiff(); if(!d.add.length&&!d.remove.length){ toast('No changes to save.',true); return; }
    var btn=document.getElementById('mSave'); if(btn){ btn.disabled=true; btn.innerHTML='<span class="ppa-spin"></span> Saving…'; }
    post('assign-bulk',{add:d.add,remove:d.remove}).then(function(r){ if(r.success){ toast('Saved '+(r.applied||0)+' membership change'+((r.applied===1)?'':'s')+'.',true); load(); } else { toast(r.error||'Failed',false); mRefreshDirty(); } });
  }

  function renderGuide(){
    body.innerHTML='<div class="ppa-guide">'
      +'<div class="ppa-gcard"><h3><i class="bi bi-grid-3x3-gap"></i> Permissions</h3><p>Pick a group on the left, then tick what it may do to each table:</p>'
        +'<ul><li><strong>List/View</strong> — open list &amp; detail pages</li><li><strong>Add</strong> — create records</li><li><strong>Edit</strong> — change records</li><li><strong>Delete</strong> — remove records</li><li><strong>Export/Print</strong> — export, print, PDF</li><li><strong>Import</strong> — bulk import</li></ul>'
        +'<p>Use the column checkbox to toggle an action for every table, or <em>Grant all</em> / <em>Revoke all</em>. Filter narrows a long table list.</p></div>'
      +'<div class="ppa-gcard"><h3><i class="bi bi-people"></i> Users</h3><p>Create, edit or remove accounts. A password must meet the app policy; leave it blank when editing to keep the current one.</p>'
        +'<p>The <i class="bi bi-tags"></i> button assigns one user to groups — a user gets the <strong>combined</strong> access of every group they are in.</p>'
        +'<p>You cannot delete your own account or the last administrator.</p></div>'
      +'<div class="ppa-gcard"><h3><i class="bi bi-ui-checks-grid"></i> Assign (bulk)</h3><p>The <strong>Assign</strong> tab is a users&nbsp;×&nbsp;groups grid built for scale — hundreds of users and dozens of groups.</p>'
        +'<ul><li>Tick a <strong>column header</strong> to add/remove a group for every filtered user.</li><li>Select rows, then <em>Add to group</em> / <em>Remove</em> in bulk.</li><li><em>Groups (n/total)</em> chooses which columns show; search and paging keep it fast.</li></ul>'
        +'<p>Nothing is written until you press <strong>Save</strong>, which sends only what changed.</p></div>'
      +'<div class="ppa-gcard"><h3><i class="bi bi-collection"></i> Groups</h3><p>Groups hold the permissions. Mark one as an <span class="ppa-badge">admin</span> group to give it full access to everything — the admin group can never be deleted.</p></div>'
      +'<div class="ppa-gcard ppa-gspan"><h3><i class="bi bi-shield-check"></i> How access is decided</h3>'
        +'<p><strong>Deny by default.</strong> If no group grants an action on a table, it is blocked — on the page <em>and</em> in the API. Administrators (owner/superadmin roles and admin groups) bypass every check.</p>'
        +'<p><strong>Changes are live.</strong> Everything you save here applies on the user&#39;s next request — no rebuild or redeploy. Use <em>Exit Admin Area</em> (top right) to return to the app.</p></div>'
      +'</div>';
  }

  function renderPerms(){
    if(!state.groups.length){ body.innerHTML='<div class="ppa-empty">No groups yet. Create one in the Groups tab.</div>'; return; }
    var g = state.groups.filter(function(x){return x.id==state.activeGid;})[0]||state.groups[0]; state.activeGid=g.id;
    var tabs = state.groups.map(function(x){ return '<button class="ppa-gitem '+(x.id==g.id?'is-active':'')+'" data-gid="'+x.id+'" title="Edit permissions for '+esc(x.name)+'"><span class="ppa-gitem-name">'+esc(x.name)+'</span>'+(+x.is_admin_group?'<span class="ppa-badge">admin</span>':'')+'</button>'; }).join('');
    var matrix;
    if(+g.is_admin_group){
      matrix='<div class="ppa-admin-note"><i class="bi bi-shield-fill-check"></i> <strong>'+esc(g.name)+'</strong> is an administrator group — it always has full access to every table.</div>';
    } else {
      var head='<thead><tr><th class="ppa-th-name" style="text-align:left" title="Data tables in this app">Table</th>'+ACTIONS.map(function(a){return '<th title="'+a[1]+' — toggle for every table with the box below"><span>'+a[1]+'</span><input type="checkbox" class="ppa-cb ppa-colall" data-act="'+a[0]+'" title="Toggle '+a[1]+' for all tables"></th>';}).join('')+'</tr></thead>';
      var rows=state.tables.map(function(t){ return '<tr><td class="ppa-tbl-cell">'+esc(t)+'</td>'+ACTIONS.map(function(a){return '<td class="ppa-c"><input type="checkbox" class="ppa-cb ppa-pcb" data-t="'+esc(t)+'" data-act="'+a[0]+'" '+(cell(g.id,t,a[0])?'checked':'')+' title="Allow '+a[1]+' on '+esc(t)+'"></td>';}).join('')+'</tr>'; }).join('');
      matrix='<div class="ppa-mwrap"><table class="ppa-matrix">'+head+'<tbody>'+(rows||'<tr><td colspan="7" class="ppa-empty">No tables.</td></tr>')+'</tbody></table></div>';
    }
    body.innerHTML='<div class="ppa-panel"><div class="ppa-glist">'+tabs+'</div><div class="ppa-card">'
      +'<div class="ppa-toolbar"><input type="text" id="ppaSearch" placeholder="Filter tables…" title="Type to narrow the table list">'
      +(+g.is_admin_group?'':'<button class="ppa-btn" id="ppaGrant" title="Grant every action on every table to this group"><i class="bi bi-check2-all"></i> Grant all</button><button class="ppa-btn" id="ppaRevoke" title="Remove every permission from this group"><i class="bi bi-x-lg"></i> Revoke all</button>')
      +'<span class="ppa-note">'+state.tables.length+' tables</span></div>'+matrix
      +(+g.is_admin_group?'':'<div class="ppa-foot"><button class="ppa-btn ppa-btn-primary" id="ppaSave" title="Save these permissions — applies immediately"><i class="bi bi-save"></i> Save permissions</button><span class="ppa-note">Applies immediately to '+esc(g.name)+'</span></div>')+'</div></div>';

    document.querySelectorAll('.ppa-gitem').forEach(function(b){ b.addEventListener('click',function(){ state.activeGid=+b.dataset.gid; renderPerms(); }); });
    document.querySelectorAll('.ppa-pcb').forEach(function(cb){ cb.addEventListener('change',function(){ setCell(g.id,cb.dataset.t,cb.dataset.act,cb.checked); syncCol(cb.dataset.act); }); });
    document.querySelectorAll('.ppa-colall').forEach(function(h){ syncCol(h.dataset.act); h.addEventListener('change',function(){ state.tables.forEach(function(t){setCell(g.id,t,h.dataset.act,h.checked);}); renderPerms(); }); });
    var s=document.getElementById('ppaSearch'); if(s) s.addEventListener('input',function(){ var q=s.value.toLowerCase(); document.querySelectorAll('.ppa-matrix tbody tr').forEach(function(tr){ var name=tr.querySelector('.ppa-tbl-cell'); if(name) tr.style.display=name.textContent.toLowerCase().indexOf(q)>=0?'':'none'; }); });
    var grant=document.getElementById('ppaGrant'); if(grant) grant.addEventListener('click',function(){ state.tables.forEach(function(t){ACTIONS.forEach(function(a){setCell(g.id,t,a[0],true);});}); renderPerms(); });
    var rev=document.getElementById('ppaRevoke'); if(rev) rev.addEventListener('click',function(){ state.tables.forEach(function(t){ACTIONS.forEach(function(a){setCell(g.id,t,a[0],false);});}); renderPerms(); });
    var save=document.getElementById('ppaSave'); if(save) save.addEventListener('click',function(){
      var payload=state.tables.map(function(t){ var c=(state.perms[g.id]||{})[t]||{}; return {group_id:g.id,table_name:t,view:c.view?1:0,add:c.add?1:0,edit:c.edit?1:0,'delete':c['delete']?1:0,export:c.export?1:0,import:c.import?1:0}; });
      post('save-perms',{permissions:payload}).then(function(r){ toast(r.success?'Permissions saved.':(r.error||'Failed'),r.success); });
    });
  }
  function syncCol(act){ var h=document.querySelector('.ppa-colall[data-act="'+act+'"]'); if(!h)return; var boxes=[].slice.call(document.querySelectorAll('.ppa-pcb[data-act="'+act+'"]')); var on=boxes.filter(function(b){return b.checked;}).length; h.checked=boxes.length>0&&on===boxes.length; h.indeterminate=on>0&&on<boxes.length; }

  function renderGroups(){
    var rows=state.groups.map(function(g){ return '<tr><td><strong>'+esc(g.name)+'</strong>'+(+g.is_admin_group?' <span class="ppa-badge">admin</span>':'')+'</td><td>'+esc(g.description||'')+'</td>'
      +'<td class="ppa-rowacts"><button class="ppa-btn ppa-btn-icon ppa-edit-g" data-id="'+g.id+'" title="Rename or edit this group"><i class="bi bi-pencil"></i></button>'
      +(+g.is_admin_group?'':'<button class="ppa-btn ppa-btn-icon ppa-btn-danger ppa-del-g" data-id="'+g.id+'" title="Delete this group"><i class="bi bi-trash"></i></button>')+'</td></tr>'; }).join('');
    body.innerHTML='<div class="ppa-card"><div class="ppa-toolbar"><strong style="flex:1">Groups</strong><button class="ppa-btn ppa-btn-primary" id="ppaAddG" title="Create a new group"><i class="bi bi-plus-lg"></i> Add group</button></div>'
      +'<div class="ppa-tblscroll"><table class="ppa-table"><thead><tr><th>Name</th><th>Description</th><th></th></tr></thead><tbody>'+(rows||'<tr><td colspan="3" class="ppa-empty">No groups yet.</td></tr>')+'</tbody></table></div></div>';
    document.getElementById('ppaAddG').addEventListener('click',function(){ groupModal(null); });
    document.querySelectorAll('.ppa-edit-g').forEach(function(b){ b.addEventListener('click',function(){ groupModal(state.groups.filter(function(g){return g.id==b.dataset.id;})[0]); }); });
    document.querySelectorAll('.ppa-del-g').forEach(function(b){ b.addEventListener('click',function(){
      Swal.fire({title:'Delete this group?',text:'Members lose its permissions.',icon:'warning',showCancelButton:true,confirmButtonText:'Delete',confirmButtonColor:'#dc2626'}).then(function(res){
        if(!res.isConfirmed)return; post('delete-group',{id:+b.dataset.id}).then(function(r){ if(r.success){toast('Group deleted.',true);load();}else toast(r.error||'Failed',false); }); });
    }); });
  }
  function groupModal(g){
    Swal.fire({title:g?'Edit group':'New group',focusConfirm:false,showCancelButton:true,confirmButtonText:g?'Save':'Create',
      html:'<div class="ppa-fields"><label class="ppa-wide"><span>Name <em>*</em></span><input id="pg_name" value="'+esc(g?g.name:'')+'"></label>'
        +'<label class="ppa-wide"><span>Description</span><input id="pg_desc" value="'+esc(g?(g.description||''):'')+'"></label>'
        +'<label class="ppa-wide" style="flex-direction:row;align-items:center;gap:8px"><input type="checkbox" id="pg_admin" style="width:16px" '+(g&&+g.is_admin_group?'checked':'')+'><span style="text-transform:none;font-size:12px">Administrator group (full access)</span></label></div>',
      preConfirm:function(){ var n=document.getElementById('pg_name').value.trim(); if(!n){Swal.showValidationMessage('Group name is required.');return false;} return {id:g?g.id:0,name:n,description:document.getElementById('pg_desc').value.trim(),is_admin_group:document.getElementById('pg_admin').checked?1:0}; }
    }).then(function(res){ if(!res.isConfirmed)return; post('save-group',res.value).then(function(r){ if(r.success){toast('Saved.',true);load();}else toast(r.error||'Failed',false); }); });
  }

  function renderUsers(){
    var gById={}; state.groups.forEach(function(g){gById[g.id]=g.name;});
    var rowHtml=function(u){ var mine=(state.assign[u.id]||[]); var chips=mine.map(function(id){return '<span class="ppa-chip">'+esc(gById[id]||('#'+id))+'</span>';}).join('')||'<span class="ppa-note">—</span>';
      var st=(u.status||'active').toLowerCase();
      return '<tr data-search="'+esc((u.username+' '+u.email+' '+u.role).toLowerCase())+'"><td><strong>'+esc(u.username)+'</strong></td><td>'+esc(u.email||'')+'</td><td>'+esc(u.role||'')+'</td>'
        +'<td><span class="ppa-status is-'+esc(st)+'">'+esc(st)+'</span></td><td>'+chips+'</td>'
        +'<td class="ppa-rowacts"><button class="ppa-btn ppa-btn-icon ppa-u-groups" data-id="'+u.id+'" title="Assign this user to groups"><i class="bi bi-tags"></i></button>'
        +'<button class="ppa-btn ppa-btn-icon ppa-u-edit" data-id="'+u.id+'" title="Edit this user"><i class="bi bi-pencil"></i></button>'
        +(u.id==state.me?'':'<button class="ppa-btn ppa-btn-icon ppa-btn-danger ppa-u-del" data-id="'+u.id+'" title="Delete this user"><i class="bi bi-trash"></i></button>')+'</td></tr>'; };
    body.innerHTML='<div class="ppa-card"><div class="ppa-toolbar"><input type="text" id="ppaUSearch" placeholder="Search users…" title="Search by username, email or role"><button class="ppa-btn ppa-btn-primary" id="ppaAddU" title="Create a new user account"><i class="bi bi-person-plus"></i> Add user</button></div>'
      +'<div class="ppa-tblscroll"><table class="ppa-table"><thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Groups</th><th></th></tr></thead><tbody id="ppaUBody">'+(state.users.map(rowHtml).join('')||'<tr><td colspan="6" class="ppa-empty">No users.</td></tr>')+'</tbody></table></div></div>';
    var us=document.getElementById('ppaUSearch'); us.addEventListener('input',function(){ var q=us.value.toLowerCase(); document.querySelectorAll('#ppaUBody tr').forEach(function(tr){ tr.style.display=(tr.dataset.search||'').indexOf(q)>=0?'':'none'; }); });
    document.getElementById('ppaAddU').addEventListener('click',function(){ userModal(null); });
    document.querySelectorAll('.ppa-u-edit').forEach(function(b){ b.addEventListener('click',function(){ userModal(state.users.filter(function(u){return u.id==b.dataset.id;})[0]); }); });
    document.querySelectorAll('.ppa-u-groups').forEach(function(b){ b.addEventListener('click',function(){ assignModal(+b.dataset.id); }); });
    document.querySelectorAll('.ppa-u-del').forEach(function(b){ b.addEventListener('click',function(){
      Swal.fire({title:'Delete this user?',icon:'warning',showCancelButton:true,confirmButtonText:'Delete',confirmButtonColor:'#dc2626'}).then(function(res){
        if(!res.isConfirmed)return; post('delete-user',{id:+b.dataset.id}).then(function(r){ if(r.success){toast('User deleted.',true);load();}else toast(r.error||'Failed',false); }); });
    }); });
  }
  function userModal(u){
    var roleOpts=state.roles.map(function(r){return '<option '+(u&&u.role===r?'selected':(!u&&r==='user'?'selected':''))+'>'+esc(r)+'</option>';}).join('');
    var stOpts=state.statuses.map(function(s){return '<option '+(u&&u.status===s?'selected':(!u&&s==='active'?'selected':''))+'>'+esc(s)+'</option>';}).join('');
    Swal.fire({title:u?'Edit user':'Add user',width:540,focusConfirm:false,showCancelButton:true,confirmButtonText:u?'Save':'Create',
      html:'<div class="ppa-fields">'
        +'<label><span>Username <em>*</em></span><input id="pu_username" value="'+esc(u?u.username:'')+'" autocomplete="off"></label>'
        +'<label><span>Email <em>*</em></span><input id="pu_email" type="email" value="'+esc(u?(u.email||''):'')+'" autocomplete="off"></label>'
        +'<label class="ppa-wide"><span>Full name</span><input id="pu_fullname" value="'+esc(u?(u.fullname||''):'')+'" autocomplete="off"></label>'
        +'<label><span>Role</span><select id="pu_role">'+roleOpts+'</select></label>'
        +'<label><span>Status</span><select id="pu_status">'+stOpts+'</select></label>'
        +'<label class="ppa-wide"><span>Password '+(u?'':'<em>*</em>')+'</span><input id="pu_password" type="password" autocomplete="new-password" placeholder="'+(u?'Leave blank to keep current':'Set a password')+'"></label>'
        +'</div>',
      preConfirm:function(){ var un=document.getElementById('pu_username').value.trim(), em=document.getElementById('pu_email').value.trim(), pw=document.getElementById('pu_password').value;
        if(!un||!em){Swal.showValidationMessage('Username and email are required.');return false;}
        if(!u&&!pw){Swal.showValidationMessage('A password is required for a new user.');return false;}
        return {id:u?u.id:0,username:un,email:em,fullname:document.getElementById('pu_fullname').value.trim(),role:document.getElementById('pu_role').value,status:document.getElementById('pu_status').value,password:pw}; }
    }).then(function(res){ if(!res.isConfirmed)return; post('save-user',res.value).then(function(r){ if(r.success){toast('Saved.',true);load();}else toast(r.error||'Failed',false); }); });
  }
  function assignModal(uid){
    var mine=(state.assign[uid]||[]).slice();
    if(!state.groups.length){ toast('Create a group first.',false); return; }
    var html='<div style="text-align:left;max-height:50vh;overflow:auto">'+state.groups.map(function(g){ return '<label style="display:flex;gap:8px;align-items:center;padding:5px 0"><input type="checkbox" value="'+g.id+'" '+(mine.indexOf(g.id)>=0?'checked':'')+'> '+esc(g.name)+(+g.is_admin_group?' (admin)':'')+'</label>'; }).join('')+'</div>';
    Swal.fire({title:'Assign groups',html:html,showCancelButton:true,confirmButtonText:'Save',focusConfirm:false,preConfirm:function(){ return [].slice.call(document.querySelectorAll('.swal2-html-container input:checked')).map(function(c){return +c.value;}); }}).then(function(res){
      if(!res.isConfirmed)return; post('assign',{user_id:uid,group_ids:res.value}).then(function(r){ if(r.success){toast('Saved.',true);load();}else toast(r.error||'Failed',false); }); });
  }

  load();
})();
</script>
<?php pp_shell_end(); ?>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
</body></html>
ADMINPAGE;
        // Bake the Designer-driven chrome into the page (build-time literals).
        $page = strtr($page, [
            '__PPA_TITLE_HTML__' => htmlspecialchars($heading, ENT_QUOTES),
            '__PPA_TITLE_PHP__'  => str_replace(['\\', "'"], ['\\\\', "\\'"], $heading),
            '__PPA_SUBTITLE__'   => htmlspecialchars($subtitle, ENT_QUOTES),
            '__PPA_EXITLABEL__'  => htmlspecialchars($exitLabel, ENT_QUOTES),
            '__PPA_TABS__'       => $tabsHtml,
            '__PPA_DEFAULTTAB__' => $defaultTab,
            '__PPA_ACCENT__'     => $accentStyle,
        ]);
        $written[] = $this->writeFile($adminDir . '/index.php', $page);

        return $written;
    }

    /**
     * The in-app Audit Log & Record Locks pages (Dynamic mode). Server-rendered,
     * admin-gated, and additionally gated on PP_AUDIT_MODE / PP_LOCK_MODE ===
     * 'dynamic' so they 404-style redirect unless the admin chose Dynamic. They
     * read the live Misc-configured tables (PP_AUDIT_TABLE / PP_LOCK_TABLE) — no
     * rebuild needed to see new events, only to publish a mode change.
     *
     * @return list<string>
     */
    private function generateAuditLockPages(string $outputDir): array
    {
        $written = [];
        $adminDir = $outputDir . '/generated/admin';
        if (!is_dir($adminDir)) mkdir($adminDir, 0755, true);

        // Shared, theme-aware stylesheet for both pages (mirrors the console
        // viewers). Injected into each page's <head> via __PPAL_STYLES__.
        $css = '<style>'
            . '.ppal-wrap{width:100%}.pp-mut{color:var(--pp-muted,#94a3b8)}'
            . '.ppal-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between;margin:0 0 12px}'
            . '.ppal-title{margin:0;font-size:20px;font-weight:800;display:flex;align-items:center;gap:9px}'
            . '.ppal-sub{margin:2px 0 0;color:var(--pp-muted,#64748b);font-size:13px}'
            . '.ppal-sub code,.ppal-target code{background:var(--pp-surface-soft,#f1f5f9);padding:1px 6px;border-radius:5px;font-size:.85em}'
            . '.ppal-exit{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:9px;font-size:13px;font-weight:700;text-decoration:none;color:var(--pp-danger,#dc2626);background:color-mix(in srgb,var(--pp-danger,#dc2626) 10%,transparent);border:1px solid color-mix(in srgb,var(--pp-danger,#dc2626) 28%,transparent);white-space:nowrap}'
            . '.ppal-exit:hover{background:color-mix(in srgb,var(--pp-danger,#dc2626) 18%,transparent)}'
            . '.ppal-filters{display:flex;gap:8px;flex-wrap:wrap;align-items:end;margin-bottom:12px}'
            . '.ppal-filters label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:var(--pp-muted,#64748b);font-weight:700;margin-bottom:2px}'
            . '.ppal-filters .form-select-sm,.ppal-filters .form-control-sm{min-width:9rem}'
            . '.ppal-scroll{border:1px solid var(--pp-border,#e2e8f0);border-radius:11px;overflow:auto;max-height:64vh}'
            . '.ppal-table{width:100%;border-collapse:separate;border-spacing:0;font-size:13px}'
            . '.ppal-table thead th{position:sticky;top:0;background:var(--pp-surface-soft,#f1f5f9);color:var(--pp-muted,#64748b);font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:700;padding:8px 11px;text-align:left;white-space:nowrap;border-bottom:1px solid var(--pp-border,#e2e8f0)}'
            . '.ppal-table tbody td{padding:7px 11px;border-bottom:1px solid var(--pp-border,#e2e8f0);vertical-align:middle}'
            . '.ppal-table tbody tr:hover{background:var(--pp-surface-soft,#f8fafc)}'
            . '.ppal-mono{font-variant-numeric:tabular-nums;color:var(--pp-muted,#64748b);white-space:nowrap}'
            . '.ppal-empty{padding:40px;text-align:center;color:var(--pp-muted,#64748b)}'
            . '.ppal-act{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;text-transform:capitalize}'
            . '.ppal-act.g{background:rgba(25,135,84,.14);color:#198754}.ppal-act.r{background:rgba(220,53,69,.14);color:#dc3545}'
            . '.ppal-act.b{background:rgba(13,110,253,.14);color:#0d6efd}.ppal-act.p{background:rgba(111,66,193,.16);color:#6f42c1}'
            . '.ppal-act.m{background:var(--pp-surface-soft,#e9ecef);color:var(--pp-muted,#64748b)}'
            . '.ppal-det summary{cursor:pointer}.ppal-target code{white-space:nowrap}'
            . '.ppal-diff{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}'
            . '.ppal-diff th,.ppal-diff td{border:1px solid var(--pp-border,#e2e8f0);padding:3px 7px;text-align:left;vertical-align:top}'
            . '.ppal-diff .del{color:#dc3545;text-decoration:line-through}.ppal-diff .add{color:#198754}'
            . '.ppal-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;font-size:13px;color:var(--pp-muted,#64748b)}'
            . '.ppal-pager{display:inline-flex;align-items:center}'
            . '</style>';

        // ── generated/admin/audit.php ──
        $audit = <<<'AUDITPAGE'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
// Feature + mode gate: only reachable when the admin chose Dynamic and audit is on.
if (!defined('PP_AUDIT_ENABLED') || !PP_AUDIT_ENABLED || !defined('PP_AUDIT_MODE') || PP_AUDIT_MODE !== 'dynamic') {
    header('Location: ' . APP_BASE . '/'); exit;
}
if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    $_SESSION['_flash'] = ['message' => 'The Audit Log is available to administrators only.', 'type' => 'danger'];
    header('Location: ' . APP_BASE . '/generated/auth/no-access.php'); exit;
}
$T = PP_AUDIT_TABLE;

function pp_aud_actclass(string $a): string {
    $a = strtolower($a);
    if ($a === 'login' || $a === 'create') return 'g';
    if (in_array($a, ['login_failed', 'account_locked', 'delete'], true)) return 'r';
    if ($a === 'update') return 'b';
    if ($a === 'export') return 'p';
    return 'm';
}
function pp_aud_summary(array $log): string {
    $nd = json_decode((string) ($log['new_data'] ?? ''), true) ?: [];
    $od = json_decode((string) ($log['old_data'] ?? ''), true) ?: [];
    if (!empty($nd['_details'])) return htmlspecialchars((string) $nd['_details']);
    if (strtolower((string) $log['action']) === 'update' && $od && $nd) {
        $ch = array_values(array_filter(array_keys($nd), fn($k) => $k[0] !== '_' && (string) ($od[$k] ?? '') !== (string) ($nd[$k] ?? '')));
        if ($ch) return count($ch) . ' field' . (count($ch) > 1 ? 's' : '') . ': ' . htmlspecialchars(implode(', ', array_slice($ch, 0, 4)));
    }
    $keys = array_filter(array_keys($nd ?: $od), fn($k) => $k[0] !== '_');
    return $keys ? count($keys) . ' field' . (count($keys) > 1 ? 's' : '') : '<span class="pp-mut">&mdash;</span>';
}

// ── Filters (all bound as parameters) ──
$fUser  = (int) ($_GET['user_id'] ?? 0);
$fAct   = trim((string) ($_GET['action'] ?? ''));
$fFrom  = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['from'] ?? '')) ? $_GET['from'] : '';
$fTo    = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['to'] ?? '')) ? $_GET['to'] : '';
$limit  = (int) ($_GET['limit'] ?? 50);
if (!in_array($limit, [20, 50, 100], true)) $limit = 50;
$page   = max(1, (int) ($_GET['p'] ?? 1));
$offset = ($page - 1) * $limit;

$where = []; $params = [];
if ($fUser)  { $where[] = 'user_id = ?';     $params[] = $fUser; }
if ($fAct)   { $where[] = 'action = ?';      $params[] = $fAct; }
if ($fFrom)  { $where[] = 'created_at >= ?'; $params[] = $fFrom . ' 00:00:00'; }
if ($fTo)    { $where[] = 'created_at <= ?'; $params[] = $fTo . ' 23:59:59'; }
$wsql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';

$total = 0; $rows = []; $users = [];
try {
    $total = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `$T` $wsql", $params);
    $rows  = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$T` $wsql ORDER BY created_at DESC LIMIT $limit OFFSET $offset", $params) ?: [];
    foreach (\PpPhp\Core\DB::FetchAll("SELECT id, username FROM `users` ORDER BY username") ?: [] as $u) $users[(int) $u['id']] = $u['username'];
} catch (\Throwable $e) { /* table may be empty/missing */ }
$pages = max(1, (int) ceil($total / $limit));
$qs = fn(array $ov) => http_build_query(array_merge(['user_id' => $fUser ?: '', 'action' => $fAct, 'from' => $fFrom, 'to' => $fTo, 'limit' => $limit, 'p' => $page], $ov));
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Audit Log</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css">__PPAL_STYLES__</head>
<body>
<?php pp_shell_start('Audit Log', '', ''); ?>
<div class="ppal-wrap">
  <div class="ppal-head">
    <div><h1 class="ppal-title"><i class="bi bi-journal-text"></i> Audit Log</h1>
      <p class="ppal-sub">Every recorded event in this application &middot; table <code><?= htmlspecialchars($T) ?></code></p></div>
    <a href="<?= APP_BASE ?>/" class="ppal-exit"><i class="bi bi-box-arrow-left"></i> Back to app</a>
  </div>
  <form class="ppal-filters" method="get">
    <div><label>User</label><select name="user_id" class="form-select form-select-sm"><option value="">All users</option>
      <?php foreach ($users as $uid => $un): ?><option value="<?= $uid ?>" <?= $fUser === $uid ? 'selected' : '' ?>><?= htmlspecialchars($un) ?></option><?php endforeach; ?></select></div>
    <div><label>Action</label><input type="text" name="action" class="form-control form-control-sm" value="<?= htmlspecialchars($fAct) ?>" placeholder="login, update…"></div>
    <div><label>From</label><input type="date" name="from" class="form-control form-control-sm" value="<?= htmlspecialchars($fFrom) ?>"></div>
    <div><label>To</label><input type="date" name="to" class="form-control form-control-sm" value="<?= htmlspecialchars($fTo) ?>"></div>
    <div><label>Rows</label><select name="limit" class="form-select form-select-sm"><?php foreach ([20, 50, 100] as $l): ?><option value="<?= $l ?>" <?= $limit === $l ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?></select></div>
    <div><label>&nbsp;</label><button class="btn btn-sm btn-primary"><i class="bi bi-funnel me-1"></i>Apply</button></div>
  </form>
  <div class="ppal-scroll">
    <table class="ppal-table">
      <thead><tr><th>ID</th><th>User</th><th>Action</th><th>Target</th><th>Details</th><th>IP</th><th>When</th></tr></thead>
      <tbody>
      <?php if (!$rows): ?><tr><td colspan="7" class="ppal-empty"><i class="bi bi-inbox d-block mb-2" style="font-size:1.6rem"></i>No events match these filters.</td></tr><?php endif; ?>
      <?php foreach ($rows as $log):
          $uname = $log['user_id'] ? ($users[(int) $log['user_id']] ?? ('#' . $log['user_id'])) : 'System';
          $od = json_decode((string) ($log['old_data'] ?? ''), true) ?: [];
          $nd = json_decode((string) ($log['new_data'] ?? ''), true) ?: [];
          $keys = array_values(array_filter(array_unique(array_merge(array_keys($od), array_keys($nd))), fn($k) => $k[0] !== '_'));
          sort($keys);
      ?>
        <tr>
          <td class="ppal-mono"><?= (int) $log['id'] ?></td>
          <td><?= htmlspecialchars($uname) ?></td>
          <td><span class="ppal-act <?= pp_aud_actclass((string) $log['action']) ?>"><?= htmlspecialchars(str_replace('_', ' ', (string) $log['action'])) ?></span></td>
          <td><?= $log['table_name'] ? '<span class="ppal-target"><code>' . htmlspecialchars((string) $log['table_name']) . '</code>' . ($log['record_id'] !== null && $log['record_id'] !== '' ? ' #' . htmlspecialchars((string) $log['record_id']) : '') . '</span>' : '<span class="pp-mut">&mdash;</span>' ?></td>
          <td>
            <?php if ($keys): ?><details class="ppal-det"><summary><?= pp_aud_summary($log) ?></summary>
              <table class="ppal-diff"><thead><tr><th>Field</th><th>Before</th><th>After</th></tr></thead><tbody>
              <?php foreach ($keys as $k): $b = $od[$k] ?? null; $a = $nd[$k] ?? null; $ch = (string) $b !== (string) $a; ?>
                <tr><td><code><?= htmlspecialchars($k) ?></code></td>
                  <td class="<?= $ch && $b !== null ? 'del' : '' ?>"><?= $b === null ? '<span class="pp-mut">&mdash;</span>' : htmlspecialchars((string) $b) ?></td>
                  <td class="<?= $ch && $a !== null ? 'add' : '' ?>"><?= $a === null ? '<span class="pp-mut">&mdash;</span>' : htmlspecialchars((string) $a) ?></td></tr>
              <?php endforeach; ?>
              </tbody></table></details>
            <?php else: ?><?= pp_aud_summary($log) ?><?php endif; ?>
          </td>
          <td class="ppal-mono"><?= htmlspecialchars((string) ($log['ip_address'] ?? '')) ?></td>
          <td class="ppal-mono"><?= htmlspecialchars((string) ($log['created_at'] ?? '')) ?></td>
        </tr>
      <?php endforeach; ?>
      </tbody>
    </table>
  </div>
  <div class="ppal-foot"><span><?= $total ?> event<?= $total === 1 ? '' : 's' ?></span>
    <span class="ppal-pager">
      <a class="btn btn-sm btn-outline-secondary <?= $page <= 1 ? 'disabled' : '' ?>" href="?<?= $qs(['p' => $page - 1]) ?>"><i class="bi bi-chevron-left"></i></a>
      <span class="mx-2"><?= $page ?> / <?= $pages ?></span>
      <a class="btn btn-sm btn-outline-secondary <?= $page >= $pages ? 'disabled' : '' ?>" href="?<?= $qs(['p' => $page + 1]) ?>"><i class="bi bi-chevron-right"></i></a>
    </span>
  </div>
</div>
<?php pp_shell_end(); ?>
<script>window.PP_BASE='<?= APP_BASE ?>';</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
</body></html>
AUDITPAGE;
        $written[] = $this->writeFile($adminDir . '/audit.php', strtr($audit, ['__PPAL_STYLES__' => $css]));

        // ── generated/admin/locks.php ──
        $locks = <<<'LOCKSPAGE'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
if (!defined('PP_LOCK_ENABLED') || !PP_LOCK_ENABLED || !defined('PP_LOCK_MODE') || PP_LOCK_MODE !== 'dynamic') {
    header('Location: ' . APP_BASE . '/'); exit;
}
if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    $_SESSION['_flash'] = ['message' => 'Record Locks are available to administrators only.', 'type' => 'danger'];
    header('Location: ' . APP_BASE . '/generated/auth/no-access.php'); exit;
}
$T = PP_LOCK_TABLE;
$notice = '';

// Force-release (admin): by id, or all. CSRF-guarded POST.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!\PpPhp\Core\Security::CSRF()->Check($_POST['_token'] ?? '')) {
        $notice = 'Your session expired — reload and try again.';
    } elseif (($_POST['do'] ?? '') === 'release-all') {
        try { \PpPhp\Core\DB::Exec("DELETE FROM `$T`"); $notice = 'All locks released.'; } catch (\Throwable $e) {}
    } elseif (($rid = (int) ($_POST['id'] ?? 0)) > 0) {
        try { \PpPhp\Core\DB::Delete($T, ['id' => $rid]); $notice = 'Lock released.'; } catch (\Throwable $e) {}
    }
    // PRG so a refresh doesn't repeat the action.
    $_SESSION['_flash'] = ['message' => $notice, 'type' => 'success'];
    header('Location: ' . APP_BASE . '/generated/admin/locks.php'); exit;
}

try { \PpPhp\Core\DB::Exec("DELETE FROM `$T` WHERE expires_at IS NOT NULL AND expires_at < NOW()"); } catch (\Throwable $e) {}
$rows = [];
try {
    $rows = \PpPhp\Core\DB::FetchAll("SELECT l.*, u.username FROM `$T` l LEFT JOIN `users` u ON l.user_id = u.id ORDER BY l.locked_at DESC") ?: [];
} catch (\Throwable $e) {}
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Record Locks</title><link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/designer-base.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/components.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css"><link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css">__PPAL_STYLES__</head>
<body>
<?php pp_shell_start('Record Locks', '', ''); ?>
<div class="ppal-wrap">
  <div class="ppal-head">
    <div><h1 class="ppal-title"><i class="bi bi-lock-fill"></i> Record Locks</h1>
      <p class="ppal-sub"><?= count($rows) ?> active lock<?= count($rows) === 1 ? '' : 's' ?> &middot; table <code><?= htmlspecialchars($T) ?></code></p></div>
    <div class="d-flex gap-2">
      <?php if ($rows): ?><form method="post" onsubmit="return confirm('Release ALL locks? Holders may lose unsaved changes.');"><?= csrfField() ?><input type="hidden" name="do" value="release-all"><button class="btn btn-sm btn-danger"><i class="bi bi-unlock me-1"></i>Release all</button></form><?php endif; ?>
      <a href="<?= APP_BASE ?>/" class="ppal-exit"><i class="bi bi-box-arrow-left"></i> Back to app</a>
    </div>
  </div>
  <div class="ppal-scroll">
    <table class="ppal-table">
      <thead><tr><th>ID</th><th>Record</th><th>Held by</th><th>Since</th><th>Expires</th><th></th></tr></thead>
      <tbody>
      <?php if (!$rows): ?><tr><td colspan="6" class="ppal-empty"><i class="bi bi-unlock d-block mb-2" style="font-size:1.6rem"></i>No records are currently locked.</td></tr><?php endif; ?>
      <?php foreach ($rows as $l): ?>
        <tr>
          <td class="ppal-mono"><?= (int) $l['id'] ?></td>
          <td><span class="ppal-target"><code><?= htmlspecialchars((string) $l['table_name']) ?></code> #<?= htmlspecialchars((string) $l['record_id']) ?></span></td>
          <td><?= htmlspecialchars((string) ($l['username'] ?: ('#' . $l['user_id']))) ?></td>
          <td class="ppal-mono"><?= htmlspecialchars((string) ($l['locked_at'] ?? '')) ?></td>
          <td class="ppal-mono"><?= $l['expires_at'] ? htmlspecialchars((string) $l['expires_at']) : '<span class="pp-mut">&mdash;</span>' ?></td>
          <td><form method="post" onsubmit="return confirm('Release this lock? The holder may lose unsaved changes.');"><?= csrfField() ?><input type="hidden" name="id" value="<?= (int) $l['id'] ?>"><button class="btn btn-sm btn-outline-danger py-0 px-2"><i class="bi bi-unlock me-1"></i>Release</button></form></td>
        </tr>
      <?php endforeach; ?>
      </tbody>
    </table>
  </div>
</div>
<?php pp_shell_end(); ?>
<script>window.PP_BASE='<?= APP_BASE ?>';</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/js/pp.js"></script>
<script src="<?= APP_BASE ?>/assets/js/app.js"></script>
</body></html>
LOCKSPAGE;
        $written[] = $this->writeFile($adminDir . '/locks.php', strtr($locks, ['__PPAL_STYLES__' => $css]));

        return $written;
    }

    /**
     * Collect every Custom Button's Server (PHP) handler across the whole design
     * (any table / page-type / common page / nested children), keyed by the
     * button's node id. A generic deep-walk so it works whatever the page shape:
     * a node is `{type:'custom-button', id, props:{server}}`. De-duped by id.
     *
     * @return array<string,string> id => author PHP
     */
    private function collectCustomButtonHandlers(): array
    {
        $out = [];
        $walk = function ($v) use (&$walk, &$out): void {
            if (!is_array($v)) return;
            if (($v['type'] ?? null) === 'custom-button') {
                $server = trim((string) (($v['props']['server'] ?? '')));
                $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($v['id'] ?? ''));
                if ($server !== '' && $id !== '' && !isset($out[$id])) {
                    $out[$id] = $server;
                }
            }
            foreach ($v as $child) {
                if (is_array($child)) $walk($child);
            }
        };
        $walk((array) ($this->ctx['designer'] ?? []));
        return $out;
    }

    /**
     * Collect the Server (PHP) halves of every list-page "Execute code" click
     * action across all tables, keyed by the same stable $srvKey the client uses
     * (table:index — see listSettingsFor). Baked into api/list-click.php so the
     * author PHP runs server-side (never shipped to the browser). Same trust model
     * as Custom Button / Events: author code is inlined, not eval'd.
     *
     * @param array<string,mixed> $tables  keyed by table name (values are field defs)
     * @return array<string,string>  srvKey => server PHP
     */
    private function collectListClickHandlers(array $tables): array
    {
        $out = [];
        foreach (array_keys($tables) as $table) {
            foreach ($this->listSettingsFor($table)['clickActions'] as $ca) {
                $key = (string) ($ca['srvKey'] ?? '');
                $code = (string) ($ca['server'] ?? '');
                if ($key !== '' && trim($code) !== '' && !isset($out[$key])) {
                    $out[$key] = $code;
                }
            }
        }
        return $out;
    }

    private function generateApiEndpoints(string $outputDir, array $tables, string $slug = ''): array
    {
        $this->log('Generating API endpoints…');
        $written = [];
        $apiDir = $outputDir . '/api';
        if (!is_dir($apiDir)) mkdir($apiDir, 0755, true);

        // ── api/button.php — Custom Button server (PHP) dispatcher ──
        // The 3-way Custom Button (Client Before JS → Server PHP → Client After
        // JS) runs its Server half here: RequireAuth + CSRF, then a switch of the
        // author's baked PHP keyed by button id. Author code is inlined (not
        // eval'd) — same trust model as PHP snippets / Events. It reads $params
        // (from Client Before) and sets $result (returned to Client After); the
        // dbAll/dbRow/dbValue/dbExecute façade + all core helpers are in scope.
        $btnHandlers = $this->collectCustomButtonHandlers();
        $btnCases = '';
        foreach ($btnHandlers as $bid => $code) {
            $code = preg_replace('/<\?php\b|<\?=|<\?|\?>/', '', (string) $code);   // strip stray PHP tags
            $btnCases .= '        case ' . var_export((string) $bid, true) . ":\n"
                . "            /* --- author server code (Custom Button) --- */\n"
                . $code . "\n"
                . "            break;\n";
        }
        $buttonApi = "<?php\n"
            . "require_once __DIR__ . '/../_runtime.php';\n"
            . "\\PpPhp\\Core\\Security::RequireAuth();\n"
            . "header('Content-Type: application/json');\n"
            . "if ((\$_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed.']); exit; }\n"
            . "\$body = json_decode((string) file_get_contents('php://input'), true) ?: [];\n"
            . "\$__token = (string) (\$_SERVER['HTTP_X_CSRF_TOKEN'] ?? \$body['_token'] ?? '');\n"
            . "if (!\\PpPhp\\Core\\Security::CSRF()->Check(\$__token)) { http_response_code(419); echo json_encode(['success' => false, 'error' => 'Your session expired. Reload the page and try again.']); exit; }\n"
            . "\$btn = (string) (\$body['btn'] ?? '');\n"
            . "\$params = (array) (\$body['params'] ?? []);\n"
            . "\$result = [];\n"
            . "pp()->boot(\$params, ['action' => 'button']);\n"
            . "try {\n"
            . "    switch (\$btn) {\n"
            . $btnCases
            . "        default:\n"
            . "            http_response_code(404); echo json_encode(['success' => false, 'error' => 'Unknown button.']); exit;\n"
            . "    }\n"
            . "    echo json_encode(['success' => true, 'result' => \$result, 'commands' => pp()->pullCommands()]);\n"
            . "} catch (\\Throwable \$e) {\n"
            . "    http_response_code(500); echo json_encode(['success' => false, 'error' => \$e->getMessage()]);\n"
            . "}\n";
        $written[] = $this->writeFile($apiDir . '/button.php', $buttonApi);

        // ── api/list-click.php — list-page "Execute code" Server (PHP) dispatcher ──
        // The 3-way row/cell Click Action (Client Before JS → Server PHP → Client
        // After JS) runs its Server half here — the direct analog of button.php.
        // RequireAuth + CSRF, then a switch of the author's baked PHP keyed by the
        // stable $srvKey (table:index). Author code is inlined (not eval'd) — same
        // trust model as Custom Button / PHP snippets / Events. It reads $params
        // (id + row context from Client Before) and sets $result (returned to Client
        // After); the dbAll/dbRow/dbValue/dbExecute façade + core helpers are in
        // scope. Emitted only when at least one action carries server code.
        $clickHandlers = $this->collectListClickHandlers($tables);
        if ($clickHandlers !== []) {
            $clickCases = '';
            foreach ($clickHandlers as $ckey => $code) {
                $code = preg_replace('/<\?php\b|<\?=|<\?|\?>/', '', (string) $code);   // strip stray PHP tags
                $clickCases .= '        case ' . var_export((string) $ckey, true) . ":\n"
                    . "            /* --- author server code (list click action) --- */\n"
                    . $code . "\n"
                    . "            break;\n";
            }
            $clickApi = "<?php\n"
                . "require_once __DIR__ . '/../_runtime.php';\n"
                . "\\PpPhp\\Core\\Security::RequireAuth();\n"
                . "header('Content-Type: application/json');\n"
                . "if ((\$_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed.']); exit; }\n"
                . "\$body = json_decode((string) file_get_contents('php://input'), true) ?: [];\n"
                . "\$__token = (string) (\$_SERVER['HTTP_X_CSRF_TOKEN'] ?? \$body['_token'] ?? '');\n"
                . "if (!\\PpPhp\\Core\\Security::CSRF()->Check(\$__token)) { http_response_code(419); echo json_encode(['success' => false, 'error' => 'Your session expired. Reload the page and try again.']); exit; }\n"
                . "\$key = (string) (\$body['key'] ?? '');\n"
                . "\$params = (array) (\$body['params'] ?? []);\n"
                . "\$result = [];\n"
                . "pp()->boot(\$params, ['action' => 'list-click', 'table' => (string) strstr(\$key, ':', true)]);\n"
                . "try {\n"
                . "    switch (\$key) {\n"
                . $clickCases
                . "        default:\n"
                . "            http_response_code(404); echo json_encode(['success' => false, 'error' => 'Unknown action.']); exit;\n"
                . "    }\n"
                . "    echo json_encode(['success' => true, 'result' => \$result, 'commands' => pp()->pullCommands()]);\n"
                . "} catch (\\Throwable \$e) {\n"
                . "    http_response_code(500); echo json_encode(['success' => false, 'error' => \$e->getMessage()]);\n"
                . "}\n";
            $written[] = $this->writeFile($apiDir . '/list-click.php', $clickApi);
        } else {
            // No server-side click actions → make sure a stale endpoint from an
            // earlier build (with now-removed author code) can't linger & execute.
            @unlink($apiDir . '/list-click.php');
        }

        // ── api/ai-assist.php — Claude-powered record assistant ──
        // POST {table,id,task,instruction}. Auth + CSRF gated; the table is
        // whitelisted and the record is fetched under Row-Level Security, so the
        // AI only ever sees rows the caller may see. The API key stays server-side.
        $aiApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed.']); exit; }
$body = json_decode((string) file_get_contents('php://input'), true) ?: [];
if (!\PpPhp\Core\Ai\AiClient::isConfigured()) { echo json_encode(['success' => false, 'premium' => true, 'error' => 'AI is a premium feature — connect your AI provider to activate.']); exit; }
$allowed = __TABLES__;
$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($body['table'] ?? ''));
$id    = (string) ($body['id'] ?? '');
$task  = (string) ($body['task'] ?? 'summary');
$instruction = trim((string) ($body['instruction'] ?? ''));
if (!in_array($table, $allowed, true)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Unknown table.']); exit; }
$rls = class_exists('\PpPhp\Core\Auth\RowSecurity') ? \PpPhp\Core\Auth\RowSecurity::andSql($table) : '';
$pk  = function_exists('pp_pk') ? pp_pk($table) : 'id';
$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$table` WHERE `$pk` = ?" . $rls . " LIMIT 1", [$id]) ?: [];
if ($rows === []) { http_response_code(404); echo json_encode(['success' => false, 'error' => 'Record not found or not visible to you.']); exit; }
$record = $rows[0];
$presets = [
  'summary'     => 'Summarise this record in 4-6 concise bullet points a busy professional can scan.',
  'next-action' => 'Based on this record, suggest the most likely next actions as a short, prioritised checklist. Be practical and specific.',
  'draft'       => 'Draft a short, formal notice/letter based on this record, ready for a person to review and send.',
];
$ask = $instruction !== '' ? $instruction : ($presets[$task] ?? $presets['summary']);
$appName = defined('APP_NAME') ? APP_NAME : 'this application';
$system = 'You are a careful, professional assistant embedded in "' . $appName . '". You help staff work with their records. Never invent facts that are not present in the data. Keep answers well-structured and concise, and ALWAYS reply in the same language as the record data (Nepali if the data is in Nepali).';
$user = $ask . "\n\nRecord (table: $table):\n" . json_encode($record, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$res = \PpPhp\Core\Ai\AiClient::complete($system, $user, ['max_tokens' => 1200]);
echo json_encode(['success' => (bool) $res['success'], 'text' => (string) ($res['text'] ?? ''), 'error' => (string) ($res['error'] ?? ''), 'model' => (string) ($res['model'] ?? '')], JSON_UNESCAPED_UNICODE);
PHP;
        $aiApi = str_replace('__TABLES__', var_export(array_keys($tables), true), $aiApi);
        $written[] = $this->writeFile($apiDir . '/ai-assist.php', $aiApi);

        // ── Document sender registry + api/doc-send.php ──
        // Collect every doc-generator that has a send-capable config (an email or
        // phone column) into a server-side registry keyed by the component id, so
        // the endpoint never trusts the client for the recipient, table, or template
        // — it re-merges server-side and sends only to the record's OWN contact.
        $docSenders = [];
        $collectSenders = function (array $components) use (&$collectSenders, &$docSenders): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'doc-generator') {
                    $p = (array) ($c['props'] ?? []);
                    $emailCol = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['emailColumn'] ?? ''));
                    $phoneCol = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['phoneColumn'] ?? ''));
                    $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
                    $docId = (string) ($c['id'] ?? '');
                    if ($docId !== '' && $table !== '' && ($emailCol !== '' || $phoneCol !== '')) {
                        $docSenders[$docId] = [
                            'table' => $table, 'emailColumn' => $emailCol, 'phoneColumn' => $phoneCol,
                            'subject' => (string) ($p['sendSubject'] ?? ''),
                            'template' => (string) ($p['template'] ?? ''),
                            'letterhead' => (string) ($p['letterhead'] ?? ''),
                            'bsPrefix' => (string) ($p['bsPrefix'] ?? 'वि.सं.'),
                            'adPrefix' => (string) ($p['adPrefix'] ?? 'इ.सं.'),
                        ];
                    }
                }
                if (!empty($c['children'])) $collectSenders((array) $c['children']);
            }
        };
        foreach ((array) ($this->ctx['designer']['pages'] ?? []) as $byType) {
            foreach ((array) $byType as $pt) {
                foreach ((array) ($pt['pages'] ?? []) as $pg) $collectSenders((array) ($pg['components'] ?? []));
            }
        }
        foreach ((array) ($this->ctx['designer']['common'] ?? []) as $pg) $collectSenders((array) ($pg['components'] ?? []));

        if ($docSenders !== []) {
            $appDir = $outputDir . '/app';
            if (!is_dir($appDir)) mkdir($appDir, 0755, true);
            $this->writeFile($appDir . '/doc-senders.php', "<?php\n\n// Document-sender registry (from doc-generator components). Server-authoritative:\n// the recipient is resolved from the record's OWN contact column — never the client.\nreturn " . var_export($docSenders, true) . ";\n");
            $sendApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed.']); exit; }
$reg = @include __DIR__ . '/../app/doc-senders.php';
if (!is_array($reg)) { echo json_encode(['success' => false, 'error' => 'Sending is not configured.']); exit; }
$body    = json_decode((string) file_get_contents('php://input'), true) ?: [];
$docId   = (string) ($body['docId'] ?? '');
$id      = (string) ($body['id'] ?? '');
$channel = ($body['channel'] ?? '') === 'sms' ? 'sms' : 'email';
// Client may supply only DISPLAY strings for the date/time (sanitised); all record
// content is merged server-side from the RLS-scoped row.
$cToday = preg_replace('/[<>\r\n]/', '', mb_substr((string) ($body['today'] ?? ''), 0, 60));
$cTime  = preg_replace('/[<>\r\n]/', '', mb_substr((string) ($body['time'] ?? ''), 0, 40));
$cfg = $reg[$docId] ?? null;
if (!is_array($cfg)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Unknown document.']); exit; }
$table = (string) $cfg['table'];
$col   = $channel === 'sms' ? (string) ($cfg['phoneColumn'] ?? '') : (string) ($cfg['emailColumn'] ?? '');
if ($col === '') { echo json_encode(['success' => false, 'error' => 'This document is not set up to send by ' . $channel . '.']); exit; }
// Premium gate (defense in depth — the client also hides the button): the channel
// must be configured AND the user entitled.
$__ent = function_exists('pp_entitlements') ? pp_entitlements() : ['email' => true, 'sms' => true];
if (empty($__ent[$channel])) { echo json_encode(['success' => false, 'premium' => true, 'error' => ucfirst($channel) . ' is a premium feature — please contact your administrator.']); exit; }
$rls = class_exists('\PpPhp\Core\Auth\RowSecurity') ? \PpPhp\Core\Auth\RowSecurity::andSql($table) : '';
$pk  = function_exists('pp_pk') ? pp_pk($table) : 'id';
$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$table` WHERE `$pk` = ?" . $rls . " LIMIT 1", [$id]) ?: [];
if ($rows === []) { http_response_code(404); echo json_encode(['success' => false, 'error' => 'Record not found or not visible to you.']); exit; }
$rec = $rows[0];
$to = trim((string) ($rec[$col] ?? ''));
if ($to === '') { echo json_encode(['success' => false, 'error' => 'No ' . $channel . ' address on file for this record.']); exit; }
// Server-side merge: {{col}} (auto era-prefix on *_bs/*_ad), {{today}}/{{time}} from the sanitised client strings.
$mergeFn = function (string $tpl) use ($rec, $cfg, $cToday, $cTime): string {
    return preg_replace_callback('/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/', function ($m) use ($rec, $cfg, $cToday, $cTime) {
        $k = $m[1]; $lk = strtolower($k);
        if ($lk === 'time') return $cTime;
        if ($lk === 'today' || $lk === 'now') return $cToday;
        $v = (string) ($rec[$k] ?? '');
        if ($v !== '') { if (preg_match('/_bs$/i', $k)) $v = trim((string) $cfg['bsPrefix']) . ' ' . strtr($v, '0123456789', "\u{0966}\u{0967}\u{0968}\u{0969}\u{096A}\u{096B}\u{096C}\u{096D}\u{096E}\u{096F}"); elseif (preg_match('/_ad$/i', $k)) $v = trim((string) $cfg['adPrefix']) . ' ' . $v; }
        return $v;
    }, $tpl);
};
$lh = trim((string) ($cfg['letterhead'] ?? ''));
$bodyText = ($lh !== '' ? $mergeFn($lh) . "\n\n" : '') . $mergeFn((string) $cfg['template']);
$subject = trim($mergeFn((string) ($cfg['subject'] ?? ''))) ?: (defined('APP_NAME') ? APP_NAME : 'Notice');
// Rate limit + audit log (self-creating): at most one send per record+channel per 30s.
$uid = (int) ($_SESSION['user']['id'] ?? 0);
try {
    \PpPhp\Core\DB::Query("CREATE TABLE IF NOT EXISTS doc_sends (id INT AUTO_INCREMENT PRIMARY KEY, doc_id VARCHAR(64), table_name VARCHAR(64), record_id VARCHAR(64), channel VARCHAR(8), recipient VARCHAR(190), user_id INT, status VARCHAR(16), sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(record_id), INDEX(sent_at))");
    $recent = \PpPhp\Core\DB::FetchAll("SELECT id FROM doc_sends WHERE record_id = ? AND channel = ? AND sent_at > (NOW() - INTERVAL 30 SECOND) LIMIT 1", [$id, $channel]);
    if ($recent) { echo json_encode(['success' => false, 'error' => 'Just sent — please wait a moment before resending.']); exit; }
} catch (\Throwable $e) {}
if ($channel === 'email') {
    $res = \PpPhp\Core\Email::send(['to' => $to, 'subject' => $subject, 'body' => nl2br(htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8'))]);
    $ok = !empty($res['mailed']); $msg = (string) ($res['message'] ?? '');
} else {
    $res = \PpPhp\Core\SMS::Send($to, $bodyText);
    $ok = !empty($res['success']) || !empty($res['sent']); $msg = (string) ($res['message'] ?? $res['error'] ?? '');
}
try { \PpPhp\Core\DB::Query("INSERT INTO doc_sends (doc_id, table_name, record_id, channel, recipient, user_id, status) VALUES (?,?,?,?,?,?,?)", [$docId, $table, $id, $channel, mb_substr($to, 0, 190), $uid, $ok ? 'sent' : 'failed']); } catch (\Throwable $e) {}
$maskedTo = $channel === 'email' ? preg_replace('/(^.).*(@.*$)/', '$1***$2', $to) : preg_replace('/.(?=.{2})/', '•', $to);
echo json_encode(['success' => $ok, 'message' => $ok ? ('Sent to ' . $maskedTo) : ($msg ?: 'Send failed.')], JSON_UNESCAPED_UNICODE);
PHP;
            $written[] = $this->writeFile($apiDir . '/doc-send.php', $sendApi);
        }

        // ── Client Portal: config/portal.php + public portal.php ──
        // Collect portal-link components → per-table {titleColumn, publicColumns}.
        // The public page shows ONLY these whitelisted columns and verifies an
        // HMAC token, so no login is needed and nothing sensitive can leak.
        $portalTables = [];
        $collectPortal = function (array $components) use (&$collectPortal, &$portalTables): void {
            foreach ($components as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'portal-link') {
                    $p = (array) ($c['props'] ?? []);
                    $tbl = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
                    if ($tbl !== '') {
                        $pub = [];
                        foreach (preg_split('/\s*,\s*/', (string) ($p['publicColumns'] ?? '')) as $pair) {
                            $pair = trim($pair); if ($pair === '') continue;
                            $bits = explode(':', $pair, 2);
                            $col = preg_replace('/[^A-Za-z0-9_]/', '', $bits[0]); $lbl = trim($bits[1] ?? $bits[0]);
                            if ($col !== '') $pub[] = [$col, $lbl];
                        }
                        $portalTables[$tbl] = [
                            'titleColumn' => preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['titleColumn'] ?? '')),
                            'publicColumns' => $pub,
                            'heading' => (string) ($p['heading'] ?? ''),
                        ];
                    }
                }
                if (!empty($c['children'])) $collectPortal((array) $c['children']);
            }
        };
        foreach ((array) ($this->ctx['designer']['pages'] ?? []) as $byType) {
            foreach ((array) $byType as $pt) { foreach ((array) ($pt['pages'] ?? []) as $pg) $collectPortal((array) ($pg['components'] ?? [])); }
        }
        foreach ((array) ($this->ctx['designer']['common'] ?? []) as $pg) $collectPortal((array) ($pg['components'] ?? []));

        if ($portalTables !== []) {
            // Reuse an existing secret so shared links survive rebuilds.
            $portalFile = $outputDir . '/config/portal.php';
            $existing = is_file($portalFile) ? (array) (@include $portalFile) : [];
            $secret = (string) ($existing['secret'] ?? '') ?: bin2hex(random_bytes(24));
            $this->writeFile($portalFile, "<?php\n\n// Client-Portal config. 'secret' signs the login-free links; 'tables' whitelists\n// the ONLY columns shown publicly. Keep this file private.\nreturn " . var_export(['secret' => $secret, 'tables' => $portalTables], true) . ";\n");
            $portalPage = <<<'PHP'
<?php
// Public client status page — NO login. Verifies an HMAC token and shows only the
// whitelisted columns from config/portal.php. Never expose sensitive fields here.
require_once __DIR__ . '/_runtime.php';
$cfg = @include __DIR__ . '/config/portal.php';
$secret = (string) ($cfg['secret'] ?? '');
// The link payload is a plain "table:id" pair (URL-encoded in the link); the ONLY
// thing that authorises access is the HMAC token below, verified with hash_equals().
$d = (string) ($_GET['d'] ?? '');
$tok = (string) ($_GET['t'] ?? '');
[$table, $id] = array_pad(explode(':', $d, 2), 2, '');
$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) $table);
$tcfg = $cfg['tables'][$table] ?? null;
$expect = hash_hmac('sha256', $table . '|' . $id, $secret);
$appName = defined('APP_NAME') ? APP_NAME : 'Status';
$ok = is_array($tcfg) && $secret !== '' && $id !== '' && hash_equals($expect, $tok);
$row = null;
if ($ok) {
    try { $pk = function_exists('pp_pk') ? pp_pk($table) : 'id'; $row = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$table` WHERE `$pk` = ? LIMIT 1", [$id])[0] ?? null; } catch (\Throwable $e) {}
    // Respect soft-delete if the table has it.
    if (is_array($row) && array_key_exists('deleted_at', $row) && $row['deleted_at'] !== null) $row = null;
}
$e = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$title = ($row && ($tcfg['titleColumn'] ?? '') !== '') ? (string) ($row[$tcfg['titleColumn']] ?? '') : '';
?><!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex,nofollow">
<title><?= $e($appName) ?> — Status</title>
<style>
:root{--b:#4f46e5;--t:#0f172a;--m:#64748b;--bd:#e2e8f0;--bg:#f8fafc;--s:#fff}
*{box-sizing:border-box}body{margin:0;font-family:"Noto Sans Devanagari",Inter,system-ui,sans-serif;background:var(--bg);color:var(--t);line-height:1.6}
.wrap{max-width:560px;margin:6vh auto;padding:0 18px}
.card{background:var(--s);border:1px solid var(--bd);border-radius:16px;padding:28px;box-shadow:0 8px 30px rgba(2,6,23,.06)}
.brand{font-weight:800;color:var(--b);font-size:14px;letter-spacing:.3px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
h1{font-size:20px;margin:0 0 4px}.sub{color:var(--m);font-size:13px;margin-bottom:18px}
.rows{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--bd)}
.r{display:flex;justify-content:space-between;gap:12px;padding:11px 2px;border-bottom:1px solid var(--bd)}
.r .k{color:var(--m);font-size:13px}.r .v{font-weight:600;text-align:right}
.foot{color:var(--m);font-size:11.5px;margin-top:16px;text-align:center}
.err{text-align:center;padding:20px 0}.err h1{color:#dc2626}
</style></head><body><div class="wrap"><div class="card">
<div class="brand">◆ <?= $e($appName) ?></div>
<?php if (!$row): ?>
  <div class="err"><h1>Link not valid</h1><div class="sub">This status link is invalid or has expired. Please request a new one.</div></div>
<?php else: ?>
  <h1><?= $e($title) ?: 'Case status' ?></h1>
  <div class="sub"><?= $e($tcfg['heading'] ?? 'Current status') ?></div>
  <div class="rows">
  <?php foreach ((array) ($tcfg['publicColumns'] ?? []) as $pc): $col = $pc[0]; $lbl = $pc[1]; if (!array_key_exists($col, $row)) continue; ?>
    <div class="r"><span class="k"><?= $e($lbl) ?></span><span class="v"><?= $e($row[$col]) ?: '—' ?></span></div>
  <?php endforeach; ?>
  </div>
<?php endif; ?>
<div class="foot">Read-only status page · <?= $e($appName) ?></div>
</div></div></body></html>
PHP;
            $written[] = $this->writeFile($outputDir . '/portal.php', $portalPage);
        }

        // ── api/list.php ──
        $tablesJson = json_encode(array_keys($tables));
        $listContent = <<<PHP
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

\$validTables = $tablesJson;
\$table = \$_GET['table'] ?? '';
if (!in_array(\$table, \$validTables, true)) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid table.']); exit;
}

\$page = max(1, (int)(\$_GET['page'] ?? 1));
\$perPage = (int)(\$_GET['perPage'] ?? 25);
if (\$perPage < 1 || \$perPage > 100) \$perPage = 25;
// (string) casts guard trim()/strtoupper() against array-valued params (500).
\$search = trim(is_array(\$_GET['search'] ?? '') ? '' : (string) (\$_GET['search'] ?? ''));
\$sort = is_array(\$_GET['sort'] ?? '') ? pp_pk(\$table) : (string) (\$_GET['sort'] ?? pp_pk(\$table));
\$dir = strtoupper(is_array(\$_GET['dir'] ?? '') ? 'ASC' : (string) (\$_GET['dir'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';

try {
    \$where = '';
    \$params = [];
    if (\$search !== '') {
        \$columns = \PpPhp\Core\DB::Columns(\$table);
        \$likes = [];
        foreach (\$columns ?: [] as \$col) {
            \$type = strtolower(\$col['Type'] ?? '');
            if (str_contains(\$type, 'varchar') || str_contains(\$type, 'text')) {
                \$likes[] = "`{\$col['Field']}` LIKE ?";
                \$params[] = "%\$search%";
            }
        }
        if (!empty(\$likes)) \$where = 'WHERE ' . implode(' OR ', \$likes);
    }

    \$total = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `\$table` \$where", \$params);
    \$offset = (\$page - 1) * \$perPage;
    \$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` \$where ORDER BY `\$sort` \$dir LIMIT \$perPage OFFSET \$offset", \$params);
    if (\$rows === false) \$rows = [];

    echo json_encode(['success' => true, 'data' => \$rows, 'total' => \$total, 'page' => \$page, 'perPage' => \$perPage]);
} catch (\Throwable \$e) {
    http_response_code(500);
    echo json_encode(['error' => \$e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/list.php', $listContent);

        // ── api/pdf.php ── server-side (headless) PDF of a record via PdfService.
        // Only exposes tables whose PDF page-type is enabled; labels come from the
        // designed view fields. Usable from links, cron, or Mailer attachments.
        $pdfFields = [];
        foreach ($tables as $pdfTable => $pdfTblFields) {
            if (!in_array('pdf', $this->pageTypesFor((string) $pdfTable), true)) continue;
            $cols = [];
            foreach ($pdfTblFields as $pf) {
                if (!($pf['view'] ?? true)) continue;
                $pfName  = (string) $pf['name'];
                $pfLabel = (string) ($pf['label'] ?? '');
                // Use a custom designed label if one is set, else humanise the column.
                if ($pfLabel === '' || $pfLabel === $pfName) {
                    $pfLabel = ucwords(str_replace('_', ' ', $pfName));
                }
                $cols[] = ['name' => $pfName, 'label' => $pfLabel, 'type' => (string) ($pf['type'] ?? 'text')];
            }
            $pdfFields[(string) $pdfTable] = $cols;
        }
        if ($pdfFields !== []) {
            $pdfFieldsExport = var_export($pdfFields, true);
            $pdfContent = <<<PHP
<?php
// Server-side PDF of a single record — rendered headlessly with dompdf, so it
// works with JavaScript disabled and can be reused by cron jobs / email code.
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();

\$fieldsMap = $pdfFieldsExport;
\$table = (string) (\$_GET['table'] ?? '');
\$id    = (int) (\$_GET['id'] ?? 0);
if (!isset(\$fieldsMap[\$table]) || \$id < 1) { http_response_code(400); exit('Invalid PDF request.'); }
if (!\PpPhp\Core\PdfService::available()) { http_response_code(501); exit('Server-side PDF is not available in this app.'); }

\$rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` WHERE `" . pp_pk(\$table) . "` = ?", [\$id]);
if (empty(\$rows)) { http_response_code(404); exit('Record not found.'); }
\$row = \$rows[0];
if (class_exists('\\PpPhp\\Core\\Events')) {
    \PpPhp\Core\Events::fire("tables.\$table.page.processValues", ['record' => &\$row, 'action' => 'pdf']);
}

\PpPhp\Core\PdfService::stream(
    pp_pdf_record_html(\$table, \$id, \$row, \$fieldsMap[\$table]),
    preg_replace('/[^A-Za-z0-9_-]+/', '-', ucwords(str_replace('_', ' ', \$table)) . '-' . \$id),
    [
        'orientation' => in_array((string) (\$_GET['orientation'] ?? ''), ['portrait', 'landscape'], true) ? (string) \$_GET['orientation'] : 'portrait',
        'margin'      => preg_match('/^\\d{1,2}mm( \\d{1,2}mm){0,3}$/', (string) (\$_GET['margin'] ?? '')) ? (string) \$_GET['margin'] : '15mm',
        'inline'      => !empty(\$_GET['inline']),
    ]
);

/**
 * Build a dompdf-friendly (table-based, inline-styled) HTML document for a record.
 * Reusable server-side — call it from cron / Mailer with a fetched \$row.
 */
function pp_pdf_record_html(string \$table, int \$id, array \$row, array \$fields): string
{
    \$brand   = (string) (\$GLOBALS['PP_SHELL']['brand'] ?? '');
    \$caption = ucwords(str_replace('_', ' ', \$table));
    \$rowsHtml = '';
    foreach (\$fields as \$f) {
        \$val = (string) (\$row[\$f['name']] ?? '');
        if (\$f['type'] === 'date' && \$val !== '' && \$f['name'] !== 'id') \$val = date('M j, Y', strtotime(\$val));
        elseif (\$f['type'] === 'datetime' && \$val !== '') \$val = date('M j, Y g:i a', strtotime(\$val));
        elseif (\$f['type'] === 'boolean') \$val = \$val !== '' ? (\$val ? 'Yes' : 'No') : '';
        elseif (\$f['type'] === 'currency' && \$val !== '') \$val = '\$' . number_format((float) \$val, 2);
        \$rowsHtml .= '<tr><td class="lbl">' . htmlspecialchars(\$f['label']) . '</td><td>' . nl2br(htmlspecialchars(\$val)) . '</td></tr>';
    }
    \$mark = htmlspecialchars(strtoupper(substr(\$brand !== '' ? \$brand : \$caption, 0, 2)));
    \$org  = \$brand !== '' ? '<div class="org">' . htmlspecialchars(\$brand) . '</div>' : '';
    \$foot = htmlspecialchars(\$brand !== '' ? \$brand : \$caption);
    return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>'
        . 'body{font-family:"DejaVu Sans",sans-serif;color:var(--pp-text,#0f172a);font-size:11pt;margin:0;}'
        . '.head{width:100%;border-bottom:2px solid var(--pp-border,#e2e8f0);padding-bottom:10px;margin-bottom:16px;}'
        . '.mark{background:var(--pp-primary-hover,#4f46e5);color:var(--pp-primary-text,#fff);font-weight:bold;font-size:15pt;padding:9px 11px;border-radius:6px;}'
        . '.org{color:var(--pp-muted,#64748b);font-size:8pt;text-transform:uppercase;letter-spacing:1px;font-weight:bold;}'
        . '.title{font-size:17pt;font-weight:bold;}'
        . '.meta{color:var(--pp-muted,#64748b);font-size:8.5pt;text-align:right;}'
        . 'table.fields{width:100%;border-collapse:collapse;}'
        . 'table.fields td{padding:7px 4px;border-bottom:1px solid #eef2f7;vertical-align:top;}'
        . 'table.fields td.lbl{color:var(--pp-muted,#64748b);font-weight:bold;text-transform:uppercase;font-size:8.5pt;width:32%;}'
        . 'table.foot{margin-top:20px;border-top:1px solid var(--pp-border,#e2e8f0);padding-top:8px;color:var(--pp-muted,#94a3b8);font-size:8.5pt;width:100%;}'
        . '</style></head><body>'
        . '<table class="head" cellpadding="0" cellspacing="0"><tr>'
        . '<td width="56" valign="middle"><span class="mark">' . \$mark . '</span></td>'
        . '<td valign="middle">' . \$org . '<div class="title">' . htmlspecialchars(\$caption) . ' &middot; #' . \$id . '</div></td>'
        . '<td class="meta" valign="middle">1 record<br>' . date('M j, Y g:i a') . '</td>'
        . '</tr></table>'
        . '<table class="fields">' . \$rowsHtml . '</table>'
        . '<table class="foot"><tr><td>' . \$foot . '</td><td align="right">' . date('Y-m-d H:i') . '</td></tr></table>'
        . '</body></html>';
}
PHP;
            $written[] = $this->writeFile($apiDir . '/pdf.php', $pdfContent);
        }

        // ── api/save.php ──
        $saveContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['table'] ?? ''));
$id = (int)($_POST['id'] ?? 0);
pp_perm_gate($id > 0 ? 'edit' : 'add', $table);

try {
    // Get columns for this table (driver-aware: works on MySQL and the Virtual DB)
    $columns = \PpPhp\Core\DB::Columns($table) ?: false;
    if ($columns === false) {
        http_response_code(400); echo json_encode(['error' => 'Invalid table.']); exit;
    }

    $data = [];
    foreach ($columns as $col) {
        $field = $col['Field'];
        if (($col['Key'] ?? '') === 'PRI') continue;   // never write the primary key (any name)
        if (isset($_POST[$field])) {
            $data[$field] = $_POST[$field];
        }
    }

    if ($id > 0) {
        // Update
        $affected = \PpPhp\Core\DB::Update($table, $data, [pp_pk($table) => $id]);
        if ($affected !== false) {
            echo json_encode(['success' => true, 'message' => 'Record updated.', 'id' => $id]);
        } else {
            http_response_code(500); echo json_encode(['error' => 'Update failed.']);
        }
    } else {
        // Insert
        $newId = \PpPhp\Core\DB::Insert($table, $data);
        if ($newId) {
            echo json_encode(['success' => true, 'message' => 'Record created.', 'id' => $newId]);
        } else {
            http_response_code(500); echo json_encode(['error' => 'Insert failed.']);
        }
    }
} catch (\Throwable $e) {
    http_response_code(500); echo json_encode(['error' => $e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/save.php', $saveContent);

        // ── api/signature.php — persist ONE column (a signature) of ONE record.
        //    Narrow by design: only the named column is written, WHERE <key> = <id>.
        //    Auth + edit-permission gated; column & key validated against the schema;
        //    DB::Update enforces row-level security. Used by the Signature component's
        //    Save button (table + primary-key column both explicit). ──
        $signatureContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['table'] ?? ''));
$key   = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['key'] ?? pp_pk($table))); if ($key === '') $key = pp_pk($table);
$col   = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['column'] ?? ''));
$id    = (string) ($_POST['id'] ?? '');
$value = (string) ($_POST['value'] ?? '');

if ($table === '' || $col === '' || $id === '') {
    http_response_code(400); echo json_encode(['error' => 'Missing table, column or record id.']); exit;
}

pp_perm_gate('edit', $table);

try {
    $columns = \PpPhp\Core\DB::Columns($table);
    if ($columns === []) { http_response_code(400); echo json_encode(['error' => 'Invalid table.']); exit; }
    $names = array_column($columns, 'Field');
    if (!in_array($col, $names, true) || !in_array($key, $names, true)) {
        http_response_code(400); echo json_encode(['error' => 'Unknown column.']); exit;
    }
    $affected = \PpPhp\Core\DB::Update($table, [$col => $value], [$key => $id]);
    if ($affected !== false) {
        echo json_encode(['success' => true, 'message' => 'Signature saved.', 'id' => $id]);
    } else {
        http_response_code(500); echo json_encode(['error' => 'Save failed.']);
    }
} catch (\Throwable $e) {
    http_response_code(500); echo json_encode(['error' => $e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/signature.php', $signatureContent);

        // ── api/delete.php ──
        $deleteContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
@ini_set('display_errors', '0');   // keep the JSON body clean
header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

$table = $_POST['table'] ?? '';
$id = (int)($_POST['id'] ?? 0);
$ids = $_POST['ids'] ?? '';
pp_perm_gate('delete', $table);

// File/Image columns configured to remove their files when the record is
// deleted (Designer → field → "Delete file when the record is deleted").
$__upCfg  = @include __DIR__ . '/../config/upload-fields.php';
$__upCols = is_array($__upCfg) ? (array) ($__upCfg[$table] ?? []) : [];

try {
    if ($id > 0) {
        // Row-Level Security: only rows this user may see can be deleted.
        $record = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$table` WHERE `" . pp_pk($table) . "` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql($table), [$id])[0] ?? [];
        if (empty($record)) { echo json_encode(['success' => false, 'error' => 'You do not have access to that record.']); exit; }
        $__hook = \PpPhp\Core\Events::fire("tables.{$table}.page.beforeDelete", ['record' => $record, 'id' => $id]);
        if ($__hook === false) {
            echo json_encode(['success' => false, 'error' => 'Deletion was blocked by a business rule.']); exit;
        }
        $affected = \PpPhp\Core\DB::Delete($table, [pp_pk($table) => $id]);
        foreach ($__upCols as $__uc) { if (function_exists('pp_upload_purge')) pp_upload_purge($record[$__uc] ?? ''); }
        \PpPhp\Core\Auth\AuditLog::deleted($table, $id, $record);
        \PpPhp\Core\Auth\RecordLock::release($table, $id);
        \PpPhp\Core\Events::fire("tables.{$table}.page.afterDelete", ['id' => $id, 'record' => $record]);
        echo json_encode(['success' => true, 'message' => 'Record deleted.', 'affected' => $affected]);
    } elseif ($ids) {
        $idArr = array_map('intval', explode(',', $ids));
        $affected = 0;
        $deletedIds = [];
        $deletedRecords = [];
        foreach ($idArr as $delId) {
            if ($delId > 0) {
                $record = \PpPhp\Core\DB::FetchAll("SELECT * FROM `$table` WHERE `" . pp_pk($table) . "` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql($table), [$delId])[0] ?? [];
                if (empty($record)) continue; // not visible to this user under RLS
                $__hook = \PpPhp\Core\Events::fire("tables.{$table}.page.beforeDelete", ['record' => $record, 'id' => $delId]);
                if ($__hook === false) continue;
                $affected += \PpPhp\Core\DB::Delete($table, [pp_pk($table) => $delId]);
                foreach ($__upCols as $__uc) { if (function_exists('pp_upload_purge')) pp_upload_purge($record[$__uc] ?? ''); }
                \PpPhp\Core\Auth\AuditLog::deleted($table, $delId, $record);
                \PpPhp\Core\Events::fire("tables.{$table}.page.afterDelete", ['id' => $delId, 'record' => $record]);
                $deletedIds[] = $delId;
                $deletedRecords[] = $record;
            }
        }
        \PpPhp\Core\Events::fire("tables.{$table}.page.afterBulkDelete", ['ids' => $deletedIds, 'records' => $deletedRecords]);
        echo json_encode(['success' => true, 'message' => "$affected record(s) deleted.", 'affected' => $affected]);
    } else {
        http_response_code(400); echo json_encode(['error' => 'No ID provided.']);
    }
} catch (\Throwable $e) {
    http_response_code(500); echo json_encode(['error' => $e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/delete.php', $deleteContent);

        // config/upload-fields.php — per-table File/Image columns that should have
        // their stored files removed when the record is deleted. api/delete.php
        // consults this to purge storage objects (StorageRef-aware, driver-agnostic).
        $uploadPurgeReg = [];
        foreach (array_keys($tables) as $tn) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            $cols = [];
            foreach ($this->editControlsFor($tn) as $fn => $meta) {
                if (($meta['editControl'] ?? '') !== 'FileImage') continue;
                $es = (array) ($meta['editSettings'] ?? []);
                if (in_array(strtolower((string) ($es['deleteOnRecordDelete'] ?? '')), ['1', 'yes', 'true', 'on'], true)) {
                    $cols[] = $fn;
                }
            }
            if ($cols !== []) $uploadPurgeReg[$tn] = $cols;
        }
        $cfgDir = $outputDir . '/config';
        if (!is_dir($cfgDir)) { @mkdir($cfgDir, 0775, true); }
        $written[] = $this->writeFile($cfgDir . '/upload-fields.php',
            "<?php\n\n// File/Image columns whose files are deleted with the record.\n// Generated from the Designer (field → \"Delete file when the record is deleted\").\nreturn " . var_export($uploadPurgeReg, true) . ";\n");

        // config/storage.php — the cloud Storage Connections a field can upload to
        // (S3 / Google Drive / OneDrive). Emitted ONLY when the app actually uses a
        // non-local connection, so a purely self-hosted build ships no credentials.
        // The generated app needs the live credentials to reach the bucket/account,
        // so they are written for the app to read (protect the bundle accordingly).
        if (class_exists('\PpPhp\Core\Storage\StorageConfig')) {
            try {
                // Scan the field-level store (editAs + per-page edit overrides) + the
                // designed forms for any FileImage field pointing at a cloud connection.
                $usedConns = [];
                $noteConn = function ($settings) use (&$usedConns): void {
                    $cid = trim((string) (is_array($settings) ? ($settings['connection'] ?? '') : ''));
                    if ($cid !== '' && $cid !== 'local') $usedConns[$cid] = true;
                };
                $fieldsState = $slug !== '' ? \PpPhp\Core\ModuleState::load($slug, 'fields') : [];
                foreach ((array) ($fieldsState['byTable'] ?? []) as $tCfg) {
                    foreach ((array) ($tCfg['editAs'] ?? []) as $ea) {
                        if (is_array($ea) && ($ea['type'] ?? '') === 'FileImage') $noteConn($ea['settings'] ?? []);
                    }
                    foreach ((array) ($tCfg['pageAs'] ?? []) as $pages) {
                        foreach (['add', 'edit'] as $pk) {
                            $po = (array) ($pages[$pk] ?? []);
                            if (($po['type'] ?? '') === 'FileImage') $noteConn($po['settings'] ?? []);
                        }
                    }
                }
                foreach (array_keys($tables) as $tn) {
                    foreach ($this->editControlsFor((string) $tn) as $meta) {
                        if (($meta['editControl'] ?? '') === 'FileImage') $noteConn($meta['editSettings'] ?? []);
                    }
                }
                $export = [];
                if ($usedConns !== []) {
                    foreach (\PpPhp\Core\Storage\StorageConfig::all() as $c) {
                        if (isset($usedConns[$c['id'] ?? '']) && ($c['driver'] ?? '') !== 'local') {
                            $export[(string) $c['id']] = $c;   // secrets already decrypted here
                        }
                    }
                }
                if ($export !== []) {
                    $written[] = $this->writeFile($cfgDir . '/storage.php',
                        "<?php\n\n// Cloud storage connections used by File/Image fields.\n// Contains live credentials — keep this bundle private.\nreturn " . var_export($export, true) . ";\n");
                }
            } catch (\Throwable $e) { /* self-hosted only — no storage config to emit */ }
        }

        // ── api/lock.php ── (Record Locking heartbeat / release for the edit page)
        // Only meaningful when Misc → Advanced enables locking; RecordLock no-ops
        // otherwise, so the endpoint is safe to ship unconditionally.
        $lockContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

$table  = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['table'] ?? ''));
$id     = (int) ($_POST['id'] ?? 0);
$action = (string) ($_POST['action'] ?? '');
if ($table === '' || $id <= 0) { http_response_code(400); echo json_encode(['error' => 'Bad request.']); exit; }
// Only someone allowed to edit this table may hold/refresh a write lock on it.
pp_perm_gate('edit', $table);

if ($action === 'release') {
    \PpPhp\Core\Auth\RecordLock::release($table, $id);
    echo json_encode(['success' => true]);
} else { // heartbeat (default)
    $ok = \PpPhp\Core\Auth\RecordLock::heartbeat($table, $id);
    echo json_encode(['success' => $ok]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/lock.php', $lockContent);

        // ── api/columns.php ──
        $columnsContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$table = $_GET['table'] ?? '';
if (!$table) { http_response_code(400); echo json_encode(['error' => 'Table required.']); exit; }

try {
    $columns = \PpPhp\Core\DB::Columns($table);
    echo json_encode(['success' => true, 'columns' => $columns]);
} catch (\Throwable $e) {
    http_response_code(500); echo json_encode(['error' => $e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/columns.php', $columnsContent);

        // ── api/count.php ──
        $countContent = <<<PHP
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

\$tables = $tablesJson;
\$stats = [];
foreach (\$tables as \$tbl) {
    try {
        // Apply the SAME visibility as the list page so these nav/dashboard badge
        // counts never leak the true row count of a table the caller is restricted
        // from by Row-Level Security or filtered out of by the sqlWhere event. This
        // used to be a bare COUNT(*) (row-count disclosure).
        \$where = '';
        if (class_exists('\PpPhp\Core\Auth\RowSecurity')) {
            \$where = \PpPhp\Core\Auth\RowSecurity::mergeWhere(\$where, \$tbl);
        }
        \$__extra = \PpPhp\Core\Events::fire("tables.\$tbl.sql.sqlWhere", ['user' => \$_SESSION['user'] ?? [], 'params' => \$_GET]);
        if (is_string(\$__extra) && trim(\$__extra) !== '') {
            \$where = \$where === '' ? 'WHERE (' . \$__extra . ')' : \$where . ' AND (' . \$__extra . ')';
        }
        \$stats[\$tbl] = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `\$tbl` \$where");
    } catch (\Throwable) {
        \$stats[\$tbl] = 0;
    }
}
echo json_encode(['success' => true, 'stats' => \$stats]);
PHP;
        $written[] = $this->writeFile($apiDir . '/count.php', $countContent);

        // ── api/update-selected.php (bulk update from the selection bar) ──
        $updateSelectedContent = <<<PHP
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if ((\$_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

\$input = json_decode(file_get_contents('php://input'), true) ?: [];
\$table = (string) (\$input['table'] ?? '');
\$ids   = array_values(array_filter(array_map('intval', (array) (\$input['ids'] ?? []))));
\$data  = (array) (\$input['data'] ?? []);

\$validTables = $tablesJson;
if (!in_array(\$table, \$validTables, true)) {
    http_response_code(400); echo json_encode(['error' => 'Invalid table.']); exit;
}
pp_perm_gate('edit', \$table);
if (\$ids === [] || \$data === []) {
    http_response_code(400); echo json_encode(['error' => 'Nothing to update.']); exit;
}

try {
    // Whitelist columns against the real schema
    \$columns = array_column(\PpPhp\Core\DB::Columns(\$table), 'Field');
    \$clean = [];
    \$__pk = function_exists('pp_pk') ? pp_pk(\$table) : 'id';
    foreach (\$data as \$k => \$v) {
        if (\$k !== \$__pk && in_array(\$k, \$columns, true)) \$clean[\$k] = \$v;
    }
    if (\$clean === []) {
        http_response_code(400); echo json_encode(['error' => 'No valid fields.']); exit;
    }
    \$updated = 0;
    \$__pkCol = pp_pk(\$table);
    foreach (\$ids as \$id) {
        // Row-Level Security: only rows this user may SEE can be updated. Re-fetch
        // each record under RLS and skip (uncounted) if it is not visible — mirrors
        // api/delete.php, so a caller can't update a record by guessing its id.
        \$old = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` WHERE `\$__pkCol` = ?" . \PpPhp\Core\Auth\RowSecurity::andSql(\$table), [\$id])[0] ?? [];
        if (\$old === []) continue;
        \$row = \$clean;
        \$hook = \PpPhp\Core\Events::fire("tables.{\$table}.page.beforeEditSave", ['data' => &\$row, 'id' => \$id, 'old' => \$old, 'errors' => []]);
        if (\$hook === false) continue;
        if (\PpPhp\Core\DB::Update(\$table, \$row, [\$__pkCol => \$id]) !== false) {
            \PpPhp\Core\Events::fire("tables.{\$table}.page.afterEditSave", ['record' => \$row, 'id' => \$id, 'old' => \$old]);
            \$updated++;
        }
    }
    echo json_encode(['success' => true, 'updated' => \$updated]);
} catch (\Throwable \$e) {
    http_response_code(500); echo json_encode(['error' => \$e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/update-selected.php', $updateSelectedContent);

        // ── api/copy.php (Pages module → Copy Records) ──
        $copyContent = <<<PHP
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if ((\$_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405); echo json_encode(['error' => 'Method not allowed.']); exit;
}

\$table = (string) (\$_POST['table'] ?? '');
\$id = (int) (\$_POST['id'] ?? 0);
\$validTables = $tablesJson;
pp_perm_gate('add', \$table);
if (!in_array(\$table, \$validTables, true) || \$id <= 0) {
    http_response_code(400); echo json_encode(['error' => 'Invalid request.']); exit;
}

try {
    \$source = \PpPhp\Core\DB::FetchAll("SELECT * FROM `\$table` WHERE `" . pp_pk(\$table) . "` = ?", [\$id])[0] ?? null;
    if (\$source === null) {
        http_response_code(404); echo json_encode(['error' => 'Record not found.']); exit;
    }
    \$data = \$source;
    unset(\$data['id']);

    // Copy-page + add-save hooks from the Events designer
    \PpPhp\Core\Events::fire("tables.{\$table}.page.onCopyLoad", ['source' => \$source, 'defaults' => &\$data]);
    \$hook = \PpPhp\Core\Events::fire("tables.{\$table}.page.beforeAddSave", ['data' => &\$data, 'errors' => []]);
    if (\$hook === false) {
        echo json_encode(['success' => false, 'error' => 'Copy was blocked by a business rule.']); exit;
    }

    \$newId = \PpPhp\Core\DB::Insert(\$table, \$data);
    if (!\$newId) {
        http_response_code(500); echo json_encode(['error' => 'Copy failed.']); exit;
    }
    \PpPhp\Core\Events::fire("tables.{\$table}.page.afterAddSave", ['record' => \$data, 'id' => \$newId]);
    echo json_encode(['success' => true, 'id' => \$newId]);
} catch (\Throwable \$e) {
    http_response_code(500); echo json_encode(['error' => \$e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/copy.php', $copyContent);

        // ── api/event.php — call a named server action from JavaScript ──
        // A client event calls pp.run('name', params); this dispatches to the
        // PHP function action_<name>($params) (defined in Custom Functions → PHP)
        // and returns its result as JSON. Only action_* functions are callable.
        $eventApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$__in     = json_decode(file_get_contents('php://input'), true);
if (!is_array($__in)) $__in = $_POST;
$__action = (string) ($__in['action'] ?? '');
$__params = is_array($__in['params'] ?? null) ? $__in['params'] : [];

// Guard: only a valid identifier, dispatched to action_<name>() — never an
// arbitrary function, so JavaScript can't call internal PHP.
if ($__action === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $__action)) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Invalid action name.']);
    exit;
}
$__fn = 'action_' . $__action;
if (!function_exists($__fn)) {
    http_response_code(404);
    echo json_encode(['success' => false, 'error' => "Unknown action '{$__action}'. Define function {$__fn}(\$params) in Custom Functions → PHP."]);
    exit;
}
try {
    pp()->boot($__params);                       // seed pp() with params + context
    $__result = $__fn($__params);
    echo json_encode(['success' => true, 'result' => $__result, 'commands' => pp()->pullCommands()]);
} catch (\Throwable $__e) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => $__e->getMessage()]);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/event.php', $eventApi);
        $this->log('Wrote api/event.php');

        // ── api/upload.php — store an uploaded file via the modular storage core.
        // Accepts a normal single POST or a chunked/resumable sequence (uploadId +
        // offset + total). Validates by magic bytes (not just extension), routes
        // through the selected StorageRegistry driver, generates an image
        // thumbnail when GD is present, and returns a StorageRef + display URLs.
        // Fires global.file.beforeFileUpload / afterFileUpload for parity.
        $uploadContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
// A JSON API must never leak a PHP notice/warning/deprecation into its body
// (it corrupts the JSON → the client shows "Bad response"). Errors are still
// logged; they just don't print. A shutdown guard converts a late fatal into
// a clean JSON error too.
@ini_set('display_errors', '0');
header('Content-Type: application/json');
register_shutdown_function(function () {
    $e = error_get_last();
    if ($e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        if (!headers_sent()) { http_response_code(500); header('Content-Type: application/json'); }
        echo "\n" . json_encode(['success' => false, 'error' => 'Upload failed on the server.']);
    }
});

use PpPhp\Core\Storage\StorageRegistry;
use PpPhp\Core\Storage\StorageRef;

$fail = function (int $code, string $msg): void {
    http_response_code($code);
    echo json_encode(['success' => false, 'error' => $msg]);
    exit;
};

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') $fail(405, 'Method not allowed.');

// CSRF (only enforced when the app posts a token — the uploader always does).
$token = (string) ($_POST['_token'] ?? '');
if ($token !== '' && class_exists('\PpPhp\Core\Security')) {
    try { if (!\PpPhp\Core\Security::CSRF()->Check($token)) $fail(419, 'Session expired. Please reload and try again.'); }
    catch (\Throwable $e) {}
}

if (empty($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'] ?? '')) {
    $fail(400, 'No file was received.');
}
$part = $_FILES['file'];
if ((int) ($part['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    $fail(422, 'Upload error (code ' . (int) $part['error'] . ').');
}

$connection = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($_POST['connection'] ?? 'local')) ?: 'local';
$private    = !empty($_POST['private']);
$folder     = \PpPhp\Core\Storage\LocalDriver::sanitizeFolder((string) ($_POST['folder'] ?? ''));
$name       = (string) ($_POST['name'] ?? $part['name'] ?? 'file');
$name       = basename(str_replace('\\', '/', $name));
$ext        = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));

// ── baseline security: block dangerous types + PHP polyglots ──
$deny = ['php','phtml','phar','php3','php4','php5','php7','php8','pht','cgi','pl','sh','exe','com','bat','htaccess','svgz'];
if ($ext === '' || in_array($ext, $deny, true)) $fail(422, 'That file type is not allowed.');

$sniff = function (string $path): bool {           // true = looks like executable/script
    $fh = @fopen($path, 'rb'); if (!$fh) return false;
    $head = (string) fread($fh, 512); fclose($fh);
    if (stripos($head, '<?php') !== false || stripos($head, '<?=') !== false) return true;
    if (preg_match('/<\s*script[^>]*language\s*=\s*["\']?php/i', $head)) return true;
    return false;
};

// ── chunk assembly ──
$uploadId = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($_POST['uploadId'] ?? ''));
$offset   = max(0, (int) ($_POST['offset'] ?? 0));
$total    = max(0, (int) ($_POST['total'] ?? 0));

$maxBytes = 512 * 1024 * 1024;                     // hard endpoint ceiling (512MB)
if ($total > $maxBytes) $fail(413, 'File is too large.');

$tmpFinal = $part['tmp_name'];                     // the file to finalise from

if ($uploadId !== '' && $total > 0) {
    $chunkDir = (defined('BASE_PATH') ? BASE_PATH : __DIR__ . '/..') . '/' . \PpPhp\Core\Storage\LocalDriver::BASE . '/.chunks';
    if (!is_dir($chunkDir)) @mkdir($chunkDir, 0755, true);
    $partPath = $chunkDir . '/' . $uploadId . '.part';
    $have = is_file($partPath) ? (int) filesize($partPath) : 0;
    if ($offset !== $have) { echo json_encode(['success' => true, 'received' => $have]); exit; } // client resyncs
    $in = @fopen($part['tmp_name'], 'rb'); $out = @fopen($partPath, 'ab');
    if (!$in || !$out) $fail(500, 'Could not buffer the upload.');
    stream_copy_to_stream($in, $out); fclose($in); fclose($out);
    $have = (int) filesize($partPath);
    if ($have > $maxBytes) { @unlink($partPath); $fail(413, 'File is too large.'); }
    if ($have < $total) { echo json_encode(['success' => true, 'received' => $have]); exit; } // more to come
    $tmpFinal = $partPath;                          // assembled → finalise below
}

if ($sniff($tmpFinal)) { @unlink($tmpFinal); $fail(422, 'That file was rejected for security reasons.'); }

$size = (int) @filesize($tmpFinal);
// finfo objects free themselves — do NOT call finfo_close() (deprecated in PHP 8.5,
// and its notice would corrupt the JSON body → the client sees "Bad response").
$mime = function_exists('finfo_open')
    ? (function () use ($tmpFinal) { $fi = finfo_open(FILEINFO_MIME_TYPE); return $fi ? (string) (finfo_file($fi, $tmpFinal) ?: '') : ''; })()
    : (string) ($part['type'] ?? '');

$meta = ['name' => $name, 'ext' => $ext, 'size' => $size, 'mime' => $mime];

// image dimensions + thumbnail (GD, best-effort)
$info = @getimagesize($tmpFinal);
if (is_array($info)) { $meta['w'] = (int) $info[0]; $meta['h'] = (int) $info[1]; }
if (is_array($info) && function_exists('imagecreatetruecolor') && in_array($ext, ['jpg','jpeg','png','gif','webp','bmp'], true)) {
    try {
        $src = @imagecreatefromstring((string) file_get_contents($tmpFinal));
        if ($src) {
            $sw = imagesx($src); $sh = imagesy($src); $mx = 400;
            $sc = min(1, $mx / max($sw, $sh));
            $tw = max(1, (int) round($sw * $sc)); $th = max(1, (int) round($sh * $sc));
            $dst = imagecreatetruecolor($tw, $th);
            imagealphablending($dst, false); imagesavealpha($dst, true);
            imagecopyresampled($dst, $src, 0, 0, 0, 0, $tw, $th, $sw, $sh);
            $thumbTmp = tempnam(sys_get_temp_dir(), 'ppt') . '.' . $ext;
            $ok = ($ext === 'png') ? imagepng($dst, $thumbTmp)
                : (($ext === 'gif') ? imagegif($dst, $thumbTmp)
                : (($ext === 'webp' && function_exists('imagewebp')) ? imagewebp($dst, $thumbTmp)
                : imagejpeg($dst, $thumbTmp, 82)));
            if ($ok) $meta['thumbTmp'] = $thumbTmp;
        }
    } catch (\Throwable $e) {}
}

// beforeFileUpload — authors may cancel or rewrite the name.
if (\PpPhp\Core\Events::fire('global.file.beforeFileUpload', ['file' => &$meta]) === false) {
    if (!empty($_POST['uploadId'])) @unlink($tmpFinal);
    $fail(403, 'Upload cancelled.');
}

try {
    $driver = StorageRegistry::driver($connection, ['private' => $private, 'folder' => $folder]);
    $ref = $driver->put($tmpFinal, $meta);
} catch (\Throwable $e) {
    if (!empty($_POST['uploadId'])) @unlink($tmpFinal);
    $fail(422, $e->getMessage() ?: 'Could not store the file.');
}
if (!empty($_POST['uploadId'])) @unlink($tmpFinal);   // drop the assembly buffer

$url      = $driver->url($ref, ['inline' => true]);
$thumbUrl = $driver->url($ref, ['thumb' => true, 'inline' => true]);

\PpPhp\Core\Events::fire('global.file.afterFileUpload', ['file' => $ref + ['url' => $url]]);

echo json_encode(['success' => true, 'ref' => $ref, 'url' => $url, 'thumbUrl' => $thumbUrl]);
PHP;
        $written[] = $this->writeFile($apiDir . '/upload.php', $uploadContent);

        // ── api/file.php — stream a stored file (fires beforeFileDownload) ──
        // Resolves within the app's Files/ store (new StorageRef keys), then the
        // legacy uploads/ store, so private + old files both stream with authz.
        $fileContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
// Never let a PHP notice prepend bytes to a binary file stream.
@ini_set('display_errors', '0');

$f = (string) ($_GET['f'] ?? '');
if ($f === '') { http_response_code(400); echo 'Missing file parameter.'; exit; }
$inline = !empty($_GET['inline']);

$abs = null;
if (class_exists('\PpPhp\Core\Storage\LocalDriver')) {
    $abs = (new \PpPhp\Core\Storage\LocalDriver())->resolve($f);   // Files/ (public + private subpaths)
}
if ($abs === null && function_exists('pp_resolve_upload')) {
    $abs = pp_resolve_upload($f);                                 // legacy uploads/
}

$meta = ['path' => $abs, 'requested' => $f, 'name' => $abs ? basename($abs) : basename($f)];
if (\PpPhp\Core\Events::fire('global.file.beforeFileDownload', ['file' => &$meta]) === false) {
    http_response_code(403); echo 'Forbidden'; exit;
}
$abs = (string) ($meta['path'] ?? $abs);
if ($abs === '' || !is_file($abs)) { http_response_code(404); echo 'Not found'; exit; }

while (function_exists('ob_get_level') && ob_get_level() > 0) { @ob_end_clean(); }
$mime = function_exists('mime_content_type') ? (mime_content_type($abs) ?: 'application/octet-stream') : 'application/octet-stream';
$dname = str_replace('"', '', (string) ($meta['name'] ?? basename($abs)));
header('Content-Type: ' . $mime);
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $dname . '"');
header('Content-Length: ' . (string) filesize($abs));
header('X-Content-Type-Options: nosniff');
readfile($abs);
PHP;
        $written[] = $this->writeFile($apiDir . '/file.php', $fileContent);

        // ── api/webhook.php — public webhook receiver (fires onWebhookReceive) ──
        // Deliberately NOT behind RequireAuth: external services POST here. The
        // onWebhookReceive hook is responsible for verifying the signature.
        $webhookContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
header('Content-Type: application/json');

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed.']); exit;
}

$raw     = file_get_contents('php://input') ?: '';
$payload = json_decode($raw, true);
if (!is_array($payload)) $payload = $_POST;
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
$source  = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($_GET['source'] ?? ''));

// global.api.onWebhookReceive — verify signature + act on the payload.
// Return false to reject (403); return an array to send it back as the response.
$result = \PpPhp\Core\Events::fire('global.api.onWebhookReceive', [
    'payload' => $payload,
    'headers' => $headers,
    'raw'     => $raw,
    'source'  => $source,
]);

if ($result === false) {
    http_response_code(403); echo json_encode(['success' => false, 'error' => 'Rejected.']); exit;
}
if (is_array($result)) { echo json_encode($result); exit; }
echo json_encode(['success' => true]);
PHP;
        $written[] = $this->writeFile($apiDir . '/webhook.php', $webhookContent);
        $this->log('Wrote api/upload.php, api/file.php, api/webhook.php');

        // ── Files/ + uploads/ .htaccess — block script execution in the upload stores ──
        // Files/ is the File/Image field store (the modular core/Storage LocalDriver);
        // uploads/ remains for legacy pp_store_upload() server-action authors.
        $htBody = "php_flag engine off\nOptions -ExecCGI -Indexes\n<FilesMatch \"\\.(php|phtml|phar|php[0-9]|pht|cgi|pl|sh)$\">\n  Require all denied\n</FilesMatch>\n";
        foreach ([\PpPhp\Core\Storage\LocalDriver::BASE, 'uploads'] as $storeDir) {
            $abs = $outputDir . '/' . $storeDir;
            if (!is_dir($abs)) { @mkdir($abs, 0775, true); }
            $written[] = $this->writeFile($abs . '/.htaccess', $htBody);
        }
        // Files/private denies ALL direct web access (private files are streamed
        // only through api/file.php after an authorization check).
        $privDir = $outputDir . '/' . \PpPhp\Core\Storage\LocalDriver::BASE . '/private';
        if (!is_dir($privDir)) { @mkdir($privDir, 0775, true); }
        $written[] = $this->writeFile($privDir . '/.htaccess',
            "php_flag engine off\nOptions -ExecCGI -Indexes\nRequire all denied\n<IfModule !mod_authz_core.c>\n  Order deny,allow\n  Deny from all\n</IfModule>\n");

        // ── app/lookups.php + api/lookup.php — cascading lookup options ──
        // The registry whitelists every configured Lookup Wizard field, so the
        // endpoint never accepts raw table/column names from the client.
        $lookupReg = [];
        foreach (array_keys($tables) as $tn) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            foreach ($this->lookupFieldsFor($tn) as $fname => $lk) {
                $lookupReg[$tn . '.' . $fname] = [
                    'table' => $lk['table'], 'linkField' => $lk['key'], 'displayField' => $lk['display'],
                    'where' => $lk['where'], 'orderBy' => $lk['orderBy'], 'orderDir' => $lk['orderDir'],
                    'unique' => $lk['unique'], 'parentField' => $lk['parentField'], 'filterField' => $lk['filterField'],
                ];
            }
        }
        $lkAppDir = $outputDir . '/app';
        if (!is_dir($lkAppDir)) { @mkdir($lkAppDir, 0775, true); }
        $written[] = $this->writeFile($lkAppDir . '/lookups.php',
            "<?php\n\n// Lookup-field registry — generated from the Designer (Lookup Wizard settings).\n"
            . "// Consumed by api/lookup.php for dependent (cascading) dropdowns.\n\nreturn "
            . var_export($lookupReg, true) . ";\n");

        // Primary-key registry — table => its real PK column (from the live
        // schema). Consumed at runtime by pp_pk()/pp_with_id() so pages and the
        // generic endpoints address rows correctly even when the PK isn't `id`.
        $pkReg = [];
        foreach (array_keys($tables) as $tn) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            $pkReg[$tn] = $this->primaryKeyFor($tn);
        }
        $written[] = $this->writeFile($lkAppDir . '/pk.php',
            "<?php\n\n// Primary-key registry — table => PK column (from the live schema).\n"
            . "// Consumed by pp_pk() / pp_with_id() in _runtime.php.\n\nreturn "
            . var_export($pkReg, true) . ";\n");

        $lookupApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$id     = (string) ($_GET['id'] ?? '');
$parent = (string) ($_GET['parent'] ?? '');
$reg    = is_file(__DIR__ . '/../app/lookups.php') ? (require __DIR__ . '/../app/lookups.php') : [];
if (!isset($reg[$id])) {
    http_response_code(404);
    echo json_encode(['success' => false, 'error' => 'Unknown lookup.']);
    exit;
}
// Force the plugin classes to load, then reuse the Lookup Wizard's own query
// logic — WHERE / order / distinct / dependency all honoured in one place.
\PpPhp\Core\PluginRegistry::edit();
$settings = $reg[$id];
$settings['_parentValue'] = $parent;
$settings['_search'] = (string) ($_GET['q'] ?? '');   // AJAX suggestion box
$settings['_formFields'] = is_array($_GET['field'] ?? null) ? $_GET['field'] : [];   // :field.name context
$options = \PpPhp\Plugins\LookupWizard::options($settings);
echo json_encode(['success' => true, 'options' => $options]);
PHP;
        $written[] = $this->writeFile($apiDir . '/lookup.php', $lookupApi);
        $this->log('Wrote api/lookup.php + app/lookups.php (' . count($lookupReg) . ' lookup field(s))');

        // ── app/unique.php + api/validate-unique.php — async "unique" check ──
        // The registry whitelists every field carrying a Unique validation rule,
        // so the endpoint never accepts an arbitrary table/column from the client.
        $uniqueReg = [];
        foreach (array_keys($tables) as $tn) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            foreach ($this->uniqueFieldsFor($tn) as $fname) {
                $uniqueReg[$tn][$fname] = true;
            }
        }
        $written[] = $this->writeFile($lkAppDir . '/unique.php',
            "<?php\n\n// Unique-field registry — generated from the Validation rules.\n"
            . "// Consumed by api/validate-unique.php for the async availability check.\n\nreturn "
            . var_export($uniqueReg, true) . ";\n");

        $uniqueApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$reg   = is_file(__DIR__ . '/../app/unique.php') ? (require __DIR__ . '/../app/unique.php') : [];
$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['table'] ?? $_GET['table'] ?? ''));
$field = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_POST['field'] ?? $_GET['field'] ?? ''));
$value = (string) ($_POST['value'] ?? $_GET['value'] ?? '');
$id    = (string) ($_POST['id'] ?? $_GET['id'] ?? '');

// Only fields with a Unique rule are checkable — never a raw table/column.
if ($table === '' || $field === '' || empty($reg[$table][$field])) {
    http_response_code(404);
    echo json_encode(['ok' => false, 'error' => 'Unknown field.']);
    exit;
}
// An empty value can never be a duplicate here (Required handles emptiness).
if (trim($value) === '') {
    echo json_encode(['ok' => true, 'available' => true]);
    exit;
}
$sql  = "SELECT COUNT(*) AS c FROM `{$table}` WHERE `{$field}` = ?";
$args = [$value];
if ($id !== '') { $sql .= " AND `" . pp_pk($table) . "` <> ?"; $args[] = $id; }
try {
    $rows = \PpPhp\Core\DB::FetchAll($sql, $args);
    $available = ((int) ($rows[0]['c'] ?? 0)) === 0;
} catch (\Throwable $e) {
    // Fail open — the server save still enforces uniqueness authoritatively.
    echo json_encode(['ok' => true, 'available' => true]);
    exit;
}
echo json_encode(['ok' => true, 'available' => $available]);
PHP;
        $written[] = $this->writeFile($apiDir . '/validate-unique.php', $uniqueApi);
        $this->log('Wrote api/validate-unique.php + app/unique.php (' . array_sum(array_map('count', $uniqueReg)) . ' unique field(s))');

        // ── app/preview.php + api/record-preview.php — record hover-preview ──
        // A per-table whitelist of the columns a lightweight record snapshot may
        // expose (drawn from the designed field labels + list/view visibility),
        // so the endpoint never returns an arbitrary column and reuses the app's
        // own labels. Consumed by the deadline-tracker (and any future) hover card.
        $previewReg = [];
        foreach (array_keys($tables) as $tn) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            $cols = [];
            foreach ($this->templateFieldsFor($tn) as $f) {
                if (($f['enabled'] ?? true) === false) continue;
                $nm = (string) ($f['name'] ?? '');
                if ($nm === '') continue;
                $low = strtolower($nm);
                if ($low === 'deleted_at' || $low === 'id') continue;
                // FK surrogate keys read as bare numbers without lookup resolution —
                // a snapshot wants human-readable fields, so skip *_id columns.
                if (preg_match('/_id$/', $low)) continue;
                if (preg_match('/password|passwd|secret|token|salt|_hash$|^hash$/', $low)) continue;
                $vis = (array) ($f['showOn'] ?? []);
                if (($vis['view'] ?? true) === false && ($vis['list'] ?? true) === false) continue;
                $cols[$nm] = ['name' => $nm, 'label' => (string) ($f['label'] ?? $nm), 'type' => (string) ($f['type'] ?? 'text')];
                if (count($cols) >= 8) break;
            }
            $previewReg[$tn] = [
                'label' => ucwords(str_replace(['-', '_'], ' ', $tn)),
                'cols'  => array_values($cols),
            ];
        }
        $written[] = $this->writeFile($lkAppDir . '/preview.php',
            "<?php\n\n// Record-preview registry — per-table display columns + labels.\n"
            . "// Consumed by api/record-preview.php for the hover snapshot card.\n\nreturn "
            . var_export($previewReg, true) . ";\n");

        $previewApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$reg   = is_file(__DIR__ . '/../app/preview.php') ? (require __DIR__ . '/../app/preview.php') : [];
$table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_GET['table'] ?? $_POST['table'] ?? ''));
$id    = (string) ($_GET['id'] ?? $_POST['id'] ?? '');

if ($table === '' || $id === '' || !isset($reg[$table])) {
    http_response_code(404);
    echo json_encode(['ok' => false, 'error' => 'Unknown record.']);
    exit;
}
// Honour the app's own view permission, exactly like the list/view pages.
if (class_exists('\PpPhp\Core\Auth\Permissions') && !\PpPhp\Core\Auth\Permissions::can('view', $table)) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'error' => 'Not permitted.']);
    exit;
}

$def  = $reg[$table];
$cols = (array) ($def['cols'] ?? []);
$pk   = function_exists('pp_pk') ? pp_pk($table) : 'id';

try {
    // Same Row-Level-Security scope the list applies — a preview can never leak
    // a row the caller could not open.
    $rls = class_exists('\PpPhp\Core\Auth\RowSecurity') ? \PpPhp\Core\Auth\RowSecurity::andSql($table) : '';
    $row = \PpPhp\Core\DB::FetchAll("SELECT * FROM `{$table}` WHERE `{$pk}` = ?" . $rls . " LIMIT 1", [$id])[0] ?? null;
} catch (\Throwable $e) {
    $row = null;
}
if ($row === null) {
    http_response_code(404);
    echo json_encode(['ok' => false, 'error' => 'Not found.']);
    exit;
}

// If no curated columns were registered, fall back to the first few real columns.
if ($cols === []) {
    $i = 0;
    foreach ($row as $k => $v) {
        if ($k === $pk || $k === 'deleted_at') continue;
        if (preg_match('/password|passwd|secret|token|salt|_hash$|^hash$/', strtolower((string) $k))) continue;
        $cols[] = ['name' => (string) $k, 'label' => ucwords(str_replace('_', ' ', (string) $k)), 'type' => 'text'];
        if (++$i >= 6) break;
    }
}

$fields = [];
foreach ($cols as $c) {
    $name = (string) ($c['name'] ?? '');
    if ($name === '' || !array_key_exists($name, $row)) continue;
    $val = $row[$name];
    if ($val === null || $val === '') continue;
    $val = (string) $val;
    if (mb_strlen($val) > 160) $val = mb_substr($val, 0, 157) . '…';
    $fields[] = [
        'label' => (string) ($c['label'] ?? $name),
        'value' => $val,
        'type'  => (string) ($c['type'] ?? 'text'),
    ];
}

$title = $fields[0]['value'] ?? (string) $id;
echo json_encode([
    'ok'     => true,
    'title'  => $title,
    'table'  => (string) ($def['label'] ?? $table),
    'fields' => array_slice($fields, 0, 8),
]);
PHP;
        $written[] = $this->writeFile($apiDir . '/record-preview.php', $previewApi);
        $this->log('Wrote api/record-preview.php + app/preview.php (' . count($previewReg) . ' table(s))');

        // ── app/filterable.php + api/filter-values.php — Set-Filter distinct values ──
        // Whitelists every column that has a filter affordance (Fields-module Filter
        // panel flag, a filterAs control, or a list column that can carry a header
        // funnel) so the endpoint never accepts an arbitrary table/column. Lookup
        // columns carry key/display so the dropdown shows names, not foreign keys.
        $filterReg = [];
        foreach ($tables as $tn => $tfields) {
            $tn = (string) $tn;
            if ($tn === '') continue;
            $lookups = $this->lookupFieldsFor($tn);
            foreach ((array) $tfields as $f) {
                $fn = (string) ($f['name'] ?? '');
                if ($fn === '') continue;
                $hasPanel = !empty($f['filterPanel']);
                $hasCtl   = trim((string) ($f['filterAs'] ?? '')) !== '';
                $onList   = ($f['list'] ?? true) !== false;
                if (!($hasPanel || $hasCtl || $onList)) continue;
                $entry = ['type' => (string) ($f['type'] ?? 'text')];
                if (isset($lookups[$fn])) {
                    $entry['lk'] = [
                        'table'   => (string) ($lookups[$fn]['table'] ?? ''),
                        'key'     => (string) ($lookups[$fn]['key'] ?? 'id'),
                        'display' => (string) ($lookups[$fn]['display'] ?? ''),
                    ];
                }
                $filterReg[$tn][$fn] = $entry;
            }
        }
        $written[] = $this->writeFile($lkAppDir . '/filterable.php',
            "<?php\n\n// Filterable-column registry for api/filter-values.php (Set Filter).\n"
            . "// Whitelists table+column and carries lookup key/display for name resolution.\n\nreturn "
            . var_export($filterReg, true) . ";\n");

        $filterValuesApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$reg    = is_file(__DIR__ . '/../app/filterable.php') ? (require __DIR__ . '/../app/filterable.php') : [];
$table  = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_GET['table'] ?? ''));
$column = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($_GET['column'] ?? ''));
$q      = trim((string) ($_GET['q'] ?? ''));
$page   = max(0, (int) ($_GET['page'] ?? 0));
$per    = 50;

// Never accept a raw table/column — only whitelisted filterable columns.
if ($table === '' || $column === '' || !isset($reg[$table][$column])) {
    http_response_code(404);
    echo json_encode(['ok' => false, 'error' => 'Unknown filter column.']);
    exit;
}
$meta = $reg[$table][$column];

// Same visibility as the list page: the sqlWhere event + row-level security, so
// a filter dropdown never reveals values from rows the user cannot see.
$where = '';
$extra = \PpPhp\Core\Events::fire("tables.{$table}.sql.sqlWhere", ['user' => $_SESSION['user'] ?? [], 'params' => $_GET]);
if (is_string($extra) && trim($extra) !== '') {
    $where = 'WHERE (' . $extra . ')';
}
$where = \PpPhp\Core\Auth\RowSecurity::mergeWhere($where, $table);

$args = [];
if ($q !== '') {
    $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . "`{$column}` LIKE ?";
    $args[] = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $q) . '%';
}
$sql = "SELECT `{$column}` AS v, COUNT(*) AS c FROM `{$table}` {$where} "
     . "GROUP BY `{$column}` ORDER BY c DESC, v ASC LIMIT " . ($per + 1) . " OFFSET " . ($page * $per);

try {
    $rows = \PpPhp\Core\DB::FetchAll($sql, $args) ?: [];
} catch (\Throwable $e) {
    echo json_encode(['ok' => false, 'error' => 'Query failed.']);
    exit;
}
$more = count($rows) > $per;
if ($more) array_pop($rows);

// Resolve lookup display so the list shows names, not foreign keys.
$labels = [];
if (!empty($meta['lk']['table']) && !empty($meta['lk']['display'])) {
    $lt  = preg_replace('/[^A-Za-z0-9_]/', '', (string) $meta['lk']['table']);
    $lkk = preg_replace('/[^A-Za-z0-9_]/', '', (string) $meta['lk']['key']) ?: 'id';
    $lkd = preg_replace('/[^A-Za-z0-9_]/', '', (string) $meta['lk']['display']);
    $keys = array_values(array_filter(array_map(static fn($r) => (string) $r['v'], $rows), 'strlen'));
    if ($lt !== '' && $lkd !== '' && $keys !== []) {
        try {
            $in = implode(',', array_fill(0, count($keys), '?'));
            foreach (\PpPhp\Core\DB::FetchAll("SELECT `{$lkk}` AS k, `{$lkd}` AS d FROM `{$lt}` WHERE `{$lkk}` IN ($in)", $keys) ?: [] as $lr) {
                $labels[(string) $lr['k']] = (string) $lr['d'];
            }
        } catch (\Throwable $e) { /* keep raw keys */ }
    }
}

$out = [];
foreach ($rows as $r) {
    $v = (string) ($r['v'] ?? '');
    $out[] = ['v' => $v, 'label' => $labels[$v] ?? ($v === '' ? '(empty)' : $v), 'count' => (int) ($r['c'] ?? 0)];
}
echo json_encode(['ok' => true, 'values' => $out, 'more' => $more]);
PHP;
        $written[] = $this->writeFile($apiDir . '/filter-values.php', $filterValuesApi);
        $this->log('Wrote api/filter-values.php + app/filterable.php (' . array_sum(array_map('count', $filterReg)) . ' filterable column(s))');

        // ── app/charts.php + api/chart.php — data-bound dashboard charts ──
        // Every designed `chart` component is whitelisted by its component id;
        // the endpoint never accepts raw table/column names from the client.
        $chartReg = [];
        $collectCharts = function ($comps) use (&$collectCharts, &$chartReg): void {
            foreach ((array) $comps as $c) {
                if (!is_array($c)) continue;
                if (($c['type'] ?? '') === 'chart') {
                    $p = (array) ($c['props'] ?? []);
                    $t = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
                    $lc = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['labelColumn'] ?? ''));
                    $vc = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['valueColumn'] ?? ''));
                    $agg = strtolower((string) ($p['agg'] ?? 'count'));
                    if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
                    $w = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $w = preg_replace('/^(where|and)\s+/i', '', $w) ?? $w;
                    if ($t !== '' && $lc !== '' && ($c['id'] ?? '') !== '') {
                        $chartReg[(string) $c['id']] = [
                            'table' => $t, 'label' => $lc, 'value' => $vc, 'agg' => $agg, 'where' => $w,
                            'orderBy' => in_array($p['orderBy'] ?? '', ['value DESC', 'value ASC', 'label ASC', 'label DESC'], true) ? $p['orderBy'] : 'value DESC',
                            'limit' => max(1, min(100, (int) ($p['limit'] ?? 10))),
                        ];
                    }
                }
                if (is_array($c['children'] ?? null)) $collectCharts($c['children']);
            }
        };
        foreach ((array) ($this->ctx['designer']['pages'] ?? []) as $slot) {
            foreach ((array) $slot as $pt) {
                foreach ((array) ($pt['pages'] ?? []) as $pg) $collectCharts($pg['components'] ?? []);
            }
        }
        foreach ((array) ($this->ctx['designer']['common'] ?? []) as $pg) $collectCharts($pg['components'] ?? []);
        $written[] = $this->writeFile($lkAppDir . '/charts.php',
            "<?php\n\n// Chart registry — generated from the Designer (chart components).\n// Consumed by api/chart.php.\n\nreturn "
            . var_export($chartReg, true) . ";\n");

        $chartApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$id  = (string) ($_GET['id'] ?? '');
$reg = is_file(__DIR__ . '/../app/charts.php') ? (require __DIR__ . '/../app/charts.php') : [];
if (!isset($reg[$id])) {
    http_response_code(404);
    echo json_encode(['success' => false, 'error' => 'Unknown chart.']);
    exit;
}
$c = $reg[$id];
$aggSql = $c['agg'] === 'count' ? 'COUNT(*)' : strtoupper($c['agg']) . "(`{$c['value']}`)";
if ($c['agg'] !== 'count' && $c['value'] === '') { echo json_encode(['success' => false, 'error' => 'No value column.']); exit; }
$whereClause = $c['where'] !== '' && class_exists('\PpPhp\Core\SqlVars') ? \PpPhp\Core\SqlVars::expand($c['where']) : $c['where'];
$whereSql = $whereClause !== '' ? ' WHERE (' . $whereClause . ')' : '';
$order = ['value DESC' => 'v DESC', 'value ASC' => 'v ASC', 'label ASC' => 'l ASC', 'label DESC' => 'l DESC'][$c['orderBy']] ?? 'v DESC';
try {
    $rows = \PpPhp\Core\DB::FetchAll(
        "SELECT `{$c['label']}` AS l, $aggSql AS v FROM `{$c['table']}`$whereSql GROUP BY `{$c['label']}` ORDER BY $order LIMIT {$c['limit']}"
    ) ?: [];
    $labels = []; $values = [];
    foreach ($rows as $r) { $labels[] = (string) $r['l']; $values[] = (float) $r['v']; }
    echo json_encode(['success' => true, 'labels' => $labels, 'values' => $values]);
} catch (\Throwable $e) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => 'Query failed.']);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/chart.php', $chartApi);
        $this->log('Wrote api/chart.php + app/charts.php (' . count($chartReg) . ' chart(s))');

        // ── app/relations.php + api/details.php — Master–Detail previews ──
        // The relation registry is id-whitelisted (like lookups/charts): the
        // endpoint never accepts raw table/column names, only relation ids.
        $relReg = [];
        foreach ($this->relationsAll() as $r) {
            $lk = [];
            foreach ($this->lookupFieldsFor($r['detail_table']) as $lname => $ldef) {
                $lk[(string) $lname] = [
                    'table'   => (string) ($ldef['table'] ?? ''),
                    'key'     => (string) ($ldef['key'] ?? 'id'),
                    'display' => (string) ($ldef['display'] ?? ''),
                ];
            }
            $dfn = $this->functionalityFor($r['detail_table']);
            // Designer preview styling (detailsCol.preview on the master's list
            // grid) → pre-computed inline CSS the endpoint just concatenates.
            $prev = (array) (($this->detailsColFor($r['master_table'])['preview']) ?? []);
            $st = ComponentRenderer::detailSectionCss($prev);
            // Header-component styling (title / count badge / Inline Add /
            // Proceed buttons) + designed child columns, all from the list
            // design's detailsCol.preview — sanitized at BUILD time.
            $btnCss = static function (string $bgKey, string $colKey) use ($prev): string {
                $css = '';
                if (($c = ComponentRenderer::cssColor($prev[$bgKey] ?? '')) !== '') $css .= 'background:' . $c . ';border-color:' . $c . ';';
                if (($c = ComponentRenderer::cssColor($prev[$colKey] ?? '')) !== '') $css .= 'color:' . $c . ';';
                return $css;
            };
            $badgeStyled = ($prev['badgeBg'] ?? '') !== '' || ($prev['badgeColor'] ?? '') !== ''
                || ($prev['badgeSize'] ?? '') !== '' || ($prev['badgeShape'] ?? '') !== '';
            $prevCols = [];
            foreach ((array) (($prev['cols'] ?? [])[$r['id']] ?? []) as $pc) {
                $pc = (array) $pc;
                $pf = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($pc['field'] ?? ''));
                if ($pf === '') continue;
                $prevCols[] = [
                    'field'   => $pf,
                    'label'   => trim((string) ($pc['label'] ?? '')),
                    'visible' => ($pc['visible'] ?? true) !== false,
                    'align'   => in_array($pc['align'] ?? '', ['center', 'right'], true) ? $pc['align'] : '',
                    'width'   => ComponentRenderer::cssLen($pc['width'] ?? ''),
                ];
            }
            $relReg[$r['id']] = [
                'detail'      => $r['detail_table'],
                'fk'          => $r['detail_field'],
                // Optional Designer-authored extra filter on the child rows —
                // supports SQL variables (:userid, :today, :session.x …).
                'where'       => trim(str_replace(["\0", ';'], '', (string) ($r['where'] ?? $r['filter'] ?? $r['condition'] ?? ''))),
                'label'       => ucwords(str_replace(['-', '_'], ' ', $r['detail_table'])),
                'lookups'     => $lk,
                'proceed'     => !empty($r['show_proceed_link']),
                'hideIfEmpty' => !empty($r['hide_if_empty']),
                // child rows edit in place when its Pages functionality says so
                'inlineEdit'  => !empty($dfn['inlineEdit']) || !empty($dfn['spreadsheet']),
                'inlineAdd'   => !empty($dfn['inlineAdd']),
                'spreadsheet' => !empty($dfn['spreadsheet']),
                'style'       => $st,   // section / header CSS + grid class + header icon
                'title'       => trim((string) ($prev['title'] ?? '')),
                'titleCss'    => ($c = ComponentRenderer::cssColor($prev['titleColor'] ?? '')) !== ''
                    ? 'color:' . $c . ';' . (!empty($prev['titleBold']) ? 'font-weight:800;' : '')
                    : (!empty($prev['titleBold']) ? 'font-weight:800;' : ''),
                'badge'       => [
                    'hide' => !empty($prev['badgeHide']),
                    'css'  => $badgeStyled ? ComponentRenderer::badgeCss(
                        in_array($prev['badgeSize'] ?? '', ['sm', 'md', 'lg'], true) ? $prev['badgeSize'] : 'md',
                        in_array($prev['badgeShape'] ?? '', ['pill', 'rounded', 'square', 'circle'], true) ? $prev['badgeShape'] : 'pill',
                        (string) ($prev['badgeBg'] ?? ''), (string) ($prev['badgeColor'] ?? '')
                    ) : '',
                ],
                'addBtn'      => ['hide' => !empty($prev['addHide']), 'label' => trim((string) ($prev['addLabel'] ?? '')),
                    'icon' => trim((string) ($prev['addIcon'] ?? '')), 'css' => $btnCss('addBg', 'addColor')],
                'goBtn'       => ['hide' => !empty($prev['goHide']), 'label' => trim((string) ($prev['goLabel'] ?? '')),
                    'icon' => trim((string) ($prev['goIcon'] ?? '')), 'css' => $btnCss('goBg', 'goColor')],
                'grid'        => [
                    'limit'    => max(1, min(100, (int) (($prev['limit'] ?? 0) ?: 25))),
                    'orderDir' => strtoupper((string) ($prev['orderDir'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC',
                ],
                'columns'     => $prevCols,
            ];
        }
        $written[] = $this->writeFile($lkAppDir . '/relations.php',
            "<?php\n\n// Master–Detail relation registry — generated from the Tables module\n// (Relation modal). Consumed by api/details.php.\n\nreturn "
            . var_export($relReg, true) . ";\n");

        $detailsApi = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

$reg = is_file(__DIR__ . '/../app/relations.php') ? (require __DIR__ . '/../app/relations.php') : [];
$rel = (string) ($_GET['rel'] ?? '');
$key = (string) ($_GET['key'] ?? '');
if (!isset($reg[$rel]) || $key === '') {
    http_response_code(404);
    echo json_encode(['success' => false, 'error' => 'Unknown relation.']);
    exit;
}
$r = $reg[$rel];
try {
    $g = (array) ($r['grid'] ?? []);
    $gLimit = max(1, min(100, (int) ($g['limit'] ?? 25)));
    $gDir = ($g['orderDir'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC';
    // Optional extra filter (Designer relation WHERE) — SQL variables expanded.
    $extraSql = '';
    if (trim((string) ($r['where'] ?? '')) !== '' && class_exists('\PpPhp\Core\SqlVars')) {
        $extraSql = ' AND (' . \PpPhp\Core\SqlVars::expand((string) $r['where']) . ')';
    }
    $rows  = \PpPhp\Core\DB::FetchAll("SELECT * FROM `{$r['detail']}` WHERE `{$r['fk']}` = ?{$extraSql} ORDER BY `" . pp_pk($r['detail']) . "` $gDir LIMIT $gLimit", [$key]) ?: [];
    pp_with_id($rows, $r['detail']);
    $count = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `{$r['detail']}` WHERE `{$r['fk']}` = ?{$extraSql}", [$key]);
    if ($count === 0 && !empty($r['hideIfEmpty'])) { echo json_encode(['success' => true, 'html' => '', 'count' => 0]); exit; }
    // Designed columns (Designer child-preview panel) win: order, labels,
    // visibility, align/width. Fallback: runtime-derived from the first row.
    $dcols = array_values(array_filter((array) ($r['columns'] ?? []), static fn ($c) => ($c['visible'] ?? true) !== false));
    if ($dcols !== []) {
        $cols = array_map(static fn ($c) => (string) $c['field'], $dcols);
        $fields = array_map(static function ($c) use ($r) {
            $cellCss = (($c['align'] ?? '') !== '' ? 'text-align:' . $c['align'] . ';' : '')
                . (($c['width'] ?? '') !== '' ? 'width:' . $c['width'] . ';' : '');
            return ['name' => (string) $c['field'],
                'label' => ($c['label'] ?? '') !== '' ? $c['label'] : ucwords(str_replace('_', ' ', (string) $c['field'])),
                'type' => 'text', 'sortable' => false]
                + ($cellCss !== '' ? ['thStyle' => $cellCss, 'tdStyle' => $cellCss] : [])
                + (isset($r['lookups'][$c['field']]) ? ['lookup' => $r['lookups'][$c['field']]] : []);
        }, $dcols);
    } else {
        $cols = array_slice(array_values(array_filter(array_keys($rows[0] ?? []), static fn ($c) => $c !== $r['fk'])), 0, 6);
        $fields = array_map(static fn ($c) => ['name' => $c, 'label' => ucwords(str_replace('_', ' ', $c)), 'type' => 'text', 'sortable' => false]
            + (isset($r['lookups'][$c]) ? ['lookup' => $r['lookups'][$c]] : []), $cols);
    }
    // Child rows honour the detail table's Pages functionality: inline edit /
    // spreadsheet cells replace the view/edit navigation, inline add reveals
    // the template row (FK inherited from this preview's master key).
    $inlineEdit = !empty($r['inlineEdit']);
    $inlineAdd = !empty($r['inlineAdd']);
    $editable = $inlineEdit || !empty($r['spreadsheet'])
        ? array_values(array_filter($cols, static fn ($c) => $c !== 'id')) : [];
    $acts = $inlineEdit
        ? ['show' => true, 'items' => ['inline-edit' => ['icon' => 'bi-pencil-square', 'label' => pp_t('ui.edit_inline', 'Edit inline'), 'display' => 'icon']]]
        : ['show' => true, 'view' => true, 'edit' => true, 'delete' => false];
    // Designer styling (Section / Header / Grid) — theme-aware base from CSS
    // classes; $r['style'] carries the per-relation inline overrides.
    $st = (array) ($r['style'] ?? []);
    $secAttr = !empty($st['section']) ? ' style="' . htmlspecialchars($st['section']) . '"' : '';
    $headAttr = (!empty($st['header']) || !empty($st['headerColor'])) ? ' style="' . htmlspecialchars(($st['header'] ?? '') . ($st['headerColor'] ?? '')) . '"' : '';
    $hIcon = trim((string) ($st['headerIcon'] ?? '')) !== '' ? $st['headerIcon'] : 'bi-diagram-3';
    if (strpos($hIcon, ' ') === false && strpos($hIcon, 'bi-') === 0) $hIcon = 'bi ' . $hIcon;
    $hLabel = ($r['title'] ?? '') !== '' ? $r['title'] : $r['label'];
    $bd = (array) ($r['badge'] ?? []);
    $badgeHtml = empty($bd['hide'])
        ? ' <span class="badge' . (($bd['css'] ?? '') === '' ? ' bg-secondary' : '') . '"'
            . (($bd['css'] ?? '') !== '' ? ' style="' . htmlspecialchars($bd['css']) . '"' : '') . '>' . $count . '</span>'
        : '';
    $tCss = (string) ($r['titleCss'] ?? '');
    $title = empty($st['hideHeader'])
        ? '<strong' . ($tCss !== '' ? ' style="' . htmlspecialchars($tCss) . '"' : '') . '><i class="' . htmlspecialchars($hIcon) . '"></i> ' . htmlspecialchars($hLabel) . '</strong>' . $badgeHtml
        : '';
    $html = '<div class="pp-md-preview" data-pp-fk="' . htmlspecialchars($r['fk']) . '" data-pp-fkval="' . htmlspecialchars($key) . '"' . $secAttr . '>'
        . '<div class="pp-md-head"' . $headAttr . '>' . $title . '<span style="flex:1"></span>';
    $iconCls = static function (string $ic, string $fallback): string {
        $ic = trim($ic) !== '' ? trim($ic) : $fallback;
        if (strpos($ic, ' ') === false && strpos($ic, 'bi-') === 0) $ic = 'bi ' . $ic;
        return $ic;
    };
    $ab = (array) ($r['addBtn'] ?? []);
    if ($inlineAdd && empty($ab['hide'])) {
        $html .= '<button type="button" class="ppd-grid-toolbar-btn pp-inline-add-btn"'
            . (($ab['css'] ?? '') !== '' ? ' style="' . htmlspecialchars($ab['css']) . '"' : '') . '>'
            . '<i class="' . htmlspecialchars($iconCls((string) ($ab['icon'] ?? ''), 'bi-plus-lg')) . '"></i> '
            . htmlspecialchars(($ab['label'] ?? '') !== '' ? $ab['label'] : pp_t('ui.inline_add', 'Inline Add')) . '</button>';
    }
    $gb = (array) ($r['goBtn'] ?? []);
    if (!empty($r['proceed']) && empty($gb['hide'])) {
        $html .= '<a class="ppd-grid-toolbar-btn"'
            . (($gb['css'] ?? '') !== '' ? ' style="' . htmlspecialchars($gb['css']) . '"' : '')
            . ' href="' . APP_BASE . '/generated/' . $r['detail'] . '/list.php?f%5B' . $r['fk'] . '%5D=' . urlencode($key) . '">'
            . '<i class="' . htmlspecialchars($iconCls((string) ($gb['icon'] ?? ''), 'bi-box-arrow-up-right')) . '"></i> '
            . htmlspecialchars(($gb['label'] ?? '') !== '' ? $gb['label'] : pp_t('ui.proceed_details', 'Proceed to details')) . '</a>';
    }
    $gridCls = trim((!empty($r['spreadsheet']) ? 'pp-spreadsheet ' : '') . (string) ($st['gridClass'] ?? ''));
    $html .= '</div>' . renderTable($rows, $fields, $r['detail'], [
        'selection' => false,
        'actions' => $acts,
        'inlineMeta' => $editable !== [],
        'editableFields' => $editable,
        'inlineAdd' => $inlineAdd,
        'gridClass' => $gridCls,
    ]) . '</div>';
    echo json_encode(['success' => true, 'html' => $html, 'count' => $count]);
} catch (\Throwable $e) {
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => 'Query failed.']);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/details.php', $detailsApi);
        $this->log('Wrote api/details.php + app/relations.php (' . count($relReg) . ' relation(s))');

        // ── api/notifications.php (Misc → Advanced → Notifications, or the
        //    Calendar's In-app notifications) ──
        if ($this->notifBellNeeded()) {
            $notifTable = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($this->ctx['advanced']['notifTableName'] ?? 'notifications')) ?: 'notifications';
            $notifContent = <<<PHP
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

\$userId = (int) (\PpPhp\Core\Security::CurrentUserData()['id'] ?? 0);

try {
    if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && (\$_POST['action'] ?? '') === 'read') {
        \PpPhp\Core\DB::Query("UPDATE `$notifTable` SET is_read = 1 WHERE user_id = ? OR user_id IS NULL", [\$userId]);
        echo json_encode(['success' => true]);
        exit;
    }

    // Tolerate any pre-existing notification table schema: select everything
    // and normalize the fields the UI needs.
    \$rows = \PpPhp\Core\DB::FetchAll(
        "SELECT * FROM `$notifTable` WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC LIMIT 10",
        [\$userId]
    ) ?: [];
    \$items = [];
    foreach (\$rows as \$r) {
        \$items[] = [
            'id'         => \$r['id'] ?? null,
            'title'      => isset(\$r['title']) ? (string) \$r['title'] : null,
            'message'    => (string) (\$r['message'] ?? \$r['text'] ?? \$r['title'] ?? ''),
            'icon'       => isset(\$r['icon']) && \$r['icon'] !== '' ? (string) \$r['icon'] : null,
            'type'       => in_array((\$r['type'] ?? 'info'), ['info','success','warning','error'], true) ? (string) \$r['type'] : 'info',
            'link'       => \$r['link'] ?? \$r['url'] ?? null,
            'is_read'    => (int) (\$r['is_read'] ?? \$r['read'] ?? 0),
            'created_at' => (string) (\$r['created_at'] ?? ''),
        ];
    }
    try {
        \$unread = (int) \PpPhp\Core\DB::FetchOne(
            "SELECT COUNT(*) FROM `$notifTable` WHERE (user_id = ? OR user_id IS NULL) AND is_read = 0",
            [\$userId]
        );
    } catch (\Throwable) {
        \$unread = count(array_filter(\$items, static fn(array \$n): bool => \$n['is_read'] === 0));
    }
    echo json_encode(['success' => true, 'unread' => \$unread, 'items' => \$items]);
} catch (\Throwable \$e) {
    // Table may not exist yet — respond gracefully
    echo json_encode(['success' => true, 'unread' => 0, 'items' => [], 'note' => 'notifications table unavailable']);
}
PHP;
            $written[] = $this->writeFile($apiDir . '/notifications.php', $notifContent);
            $this->log("Wrote api/notifications.php (table: $notifTable)");
        }

        // ── api/realtime.php — the SSE + polling transport behind pp.realtime.
        //    Self-contained and inert until something is broadcast, so it ships
        //    to every app (the client only opens a connection when the app has
        //    real-time handlers). ──
        $rtContent = <<<'PHP'
<?php
require_once __DIR__ . '/../_runtime.php';

use PpPhp\Core\Realtime\RealtimeBus;
use PpPhp\Core\Realtime\Presence;
use PpPhp\Core\Events;

// Soft auth — real-time works with or without login. A signed-in user also
// receives their own private (user-targeted) messages; anonymous sees public.
$userId = null;
$user = [];
if (class_exists('\PpPhp\Core\Security') && \PpPhp\Core\Security::IsLoggedIn()) {
    $user = \PpPhp\Core\Security::CurrentUserData() ?: [];
    $userId = (int) ($user['id'] ?? 0) ?: null;
}

// ── Client → server actions (POST): publish, presence, leave ──
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    header('Content-Type: application/json');
    $body = json_decode((string) file_get_contents('php://input'), true);
    if (!is_array($body)) { $body = $_POST; }
    $action = (string) ($body['action'] ?? '');
    $ch = RealtimeBus::cleanChannel((string) ($body['channel'] ?? ''));
    if ($ch === '') { echo json_encode(['ok' => false, 'error' => 'no channel']); exit; }
    // A POST must pass the same authorization as subscribing to the channel.
    if (Events::fire('global.realtime.onChannelAuth', ['channel' => $ch, 'user' => $user]) === false) {
        http_response_code(403); echo json_encode(['ok' => false, 'error' => 'denied']); exit;
    }
    // Authoritative member id: signed-in users are "u<id>"; anonymous tabs send
    // their own token, namespaced "t…" so they can never spoof a real user.
    if ($userId !== null) {
        $member = 'u' . $userId;
        $mname = (string) ($user['name'] ?? $user['username'] ?? ('User ' . $userId));
    } else {
        $member = 't' . preg_replace('/[^A-Za-z0-9]/', '', (string) ($body['member'] ?? ''));
        $mname = mb_substr(trim((string) ($body['name'] ?? 'Guest')), 0, 160) ?: 'Guest';
    }

    if ($action === 'publish') {
        $event = trim((string) ($body['event'] ?? 'message')) ?: 'message';
        $data = $body['data'] ?? null;
        // The app's security boundary for browser-sent messages.
        if (Events::fire('global.realtime.onClientPublish', ['channel' => $ch, 'event' => $event, 'data' => &$data, 'user' => $user]) === false) {
            echo json_encode(['ok' => false, 'error' => 'rejected']); exit;
        }
        echo json_encode(['ok' => true, 'id' => RealtimeBus::publish($ch, $event, $data, null)]); exit;
    }
    if ($action === 'presence') {
        if (Presence::heartbeat($ch, $member, $mname)) {   // a NEW join → tell the channel now
            RealtimeBus::publish($ch, '__presence', ['members' => Presence::members($ch)], null);
        }
        echo json_encode(['ok' => true, 'members' => Presence::members($ch)]); exit;
    }
    if ($action === 'leave') {
        if (Presence::leave($ch, $member)) {
            RealtimeBus::publish($ch, '__presence', ['members' => Presence::members($ch)], null);
        }
        echo json_encode(['ok' => true]); exit;
    }
    echo json_encode(['ok' => false, 'error' => 'unknown action']); exit;
}

$channels = array_values(array_filter(array_map(
    static fn($c) => RealtimeBus::cleanChannel((string) $c),
    explode(',', (string) ($_GET['channels'] ?? ''))
), static fn($c) => $c !== ''));

// Authorise each channel through the app's onChannelAuth hook (deny => drop).
$channels = array_values(array_filter($channels, static function ($ch) use ($user) {
    return Events::fire('global.realtime.onChannelAuth', ['channel' => $ch, 'user' => $user]) !== false;
}));

$since  = max(0, (int) ($_GET['since'] ?? 0));
$hello  = (($_GET['hello'] ?? '') === '1');   // one-time baseline: "start me at now"
$stream = (($_GET['stream'] ?? '') === '1');

// Handshake — the client baselines ONCE so it never replays history, then polls
// forward from the id it was given (id > since). Doing the baseline here per
// request would re-skip any message that arrived between two polls.
if ($hello || $channels === []) {
    header('Content-Type: application/json');
    echo json_encode(['ok' => true, 'lastId' => RealtimeBus::latestId(), 'messages' => []]);
    exit;
}

if (!$stream) {
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    $msgs = RealtimeBus::since($channels, $since, $userId);
    $lastId = $since;
    foreach ($msgs as $m) { $lastId = max($lastId, (int) $m['id']); }
    echo json_encode(['ok' => true, 'lastId' => $lastId, 'messages' => $msgs]);
    exit;
}

// ── SSE stream ──
@set_time_limit(0);
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');     // stop nginx/proxy buffering the stream
echo "retry: 3000\n\n";               // EventSource auto-reconnect delay
@flush();

$last = $since;
$start = time();
// Cap the stream so a shared-hosting execution limit never kills it mid-reply;
// the browser's EventSource reconnects on its own.
while (time() - $start < 25) {
    if (connection_aborted()) { break; }
    $msgs = RealtimeBus::since($channels, $last, $userId, 100);
    foreach ($msgs as $m) {
        $last = max($last, (int) $m['id']);
        echo 'id: ' . $m['id'] . "\n";
        echo 'data: ' . json_encode($m) . "\n\n";
    }
    echo ": ping\n\n";                // keep-alive comment
    @flush();
    usleep($msgs === [] ? 1000000 : 200000);
}
PHP;
        $written[] = $this->writeFile($apiDir . '/realtime.php', $rtContent);
        $this->log('Wrote api/realtime.php (SSE + polling real-time transport)');

        return $written;
    }

    /**
     * Ship pp.js (the client utility API — pp.ajax / pp.dialog / pp.form / pp.util)
     * into the generated app so custom code (Custom Button handlers, JS snippets)
     * can use it. Read from the ONE console source so the two never drift; the
     * library degrades gracefully when SweetAlert2 is absent.
     */
    private function generatePpJs(string $outputDir): array
    {
        $src = dirname(__DIR__, 2) . '/public/assets/js/pp.js';
        if (!is_file($src)) return [];
        $jsDir = $outputDir . '/assets/js';
        if (!is_dir($jsDir)) mkdir($jsDir, 0755, true);
        $this->log('Copying assets/js/pp.js');
        return [$this->writeFile($jsDir . '/pp.js', (string) file_get_contents($src))];
    }

    private function generateAppJs(string $outputDir): array
    {
        $this->log('Generating app.js…');
        $jsDir = $outputDir . '/assets/js';
        if (!is_dir($jsDir)) mkdir($jsDir, 0755, true);

        $content = <<<'JS'
/**
 * PpPhp Generated Application Runtime
 */

(function($) {
    'use strict';

    // ── Flash messages ──
    $(document).ready(function() {
        $(document).on('click', '.ppd-flash', function() {
            $(this).fadeOut();
        });
        setTimeout(function() {
            $('.ppd-flash').fadeOut();
        }, 5000);
    });

    // ── Select all / bulk actions ──
    $(document).on('change', '.ppd-select-all', function() {
        $('.ppd-select-row').prop('checked', $(this).prop('checked'));
        toggleBulkActions();
    });

    $(document).on('change', '.ppd-select-row', function() {
        toggleBulkActions();
    });

    function toggleBulkActions() {
        var checked = $('.ppd-select-row:checked').length;
        $('#bulkActions').toggle(checked > 0);
    }

    // ── Delete single record ──
    $(document).on('click', '.ppd-action-delete', function() {
        var btn = $(this);
        var table = btn.data('table');
        var id = btn.data('id');
        if (!id) return;

        if (typeof Swal !== 'undefined') {
            Swal.fire({
                title: 'Delete Record?',
                text: 'This action cannot be undone.',
                icon: 'warning',
                showCancelButton: true,
                confirmButtonColor: '#dc3545',
                confirmButtonText: 'Delete',
            }).then(function(result) {
                if (result.isConfirmed) {
                    deleteRecord(table, id, btn);
                }
            });
        } else {
            if (confirm('Delete this record?')) {
                deleteRecord(table, id, btn);
            }
        }
    });

    function deleteRecord(table, id, btn) {
        $.post((window.PP_BASE || '') + '/api/delete.php', { table: table, id: id }, function(resp) {
            if (resp.success) {
                btn.closest('tr').fadeOut(300, function() { $(this).remove(); });
                if (typeof Swal !== 'undefined') {
                    Swal.fire('Deleted!', resp.message, 'success');
                }
            } else {
                alert(resp.error || 'Delete failed.');
            }
        }, 'json').fail(function() {
            alert('Server error. Please try again.');
        });
    }

    // ── Bulk delete ──
    $(document).on('click', '.ppd-bulk-delete', function() {
        var table = $(this).data('table');
        var ids = [];
        // "Select all matching" mode → the pre-fetched RLS-scoped id set; else the
        // ticked rows on this page.
        if (window.ppAllMatching && window.ppAllMatching.active && window.ppAllMatching.ids.length) {
            ids = window.ppAllMatching.ids.slice();
        } else {
            $('.ppd-select-row:checked').each(function() { ids.push($(this).val()); });
        }
        if (ids.length === 0) return;

        if (typeof Swal !== 'undefined') {
            Swal.fire({
                title: 'Delete ' + ids.length + ' record(s)?',
                text: 'This action cannot be undone.',
                icon: 'warning',
                showCancelButton: true,
                confirmButtonColor: '#dc3545',
                confirmButtonText: 'Delete',
            }).then(function(result) {
                if (result.isConfirmed) {
                    bulkDelete(table, ids);
                }
            });
        } else {
            if (confirm('Delete ' + ids.length + ' record(s)?')) {
                bulkDelete(table, ids);
            }
        }
    });

    function bulkDelete(table, ids) {
        var wasAllMatching = !!(window.ppAllMatching && window.ppAllMatching.active);
        $.post((window.PP_BASE || '') + '/api/delete.php', { table: table, ids: ids.join(',') }, function(resp) {
            if (resp.success) {
                if (window.ppAllMatching) { window.ppAllMatching.active = false; window.ppAllMatching.ids = []; }
                // All-matching may span many pages — reload to reflect reality;
                // otherwise fade the ticked rows out in place.
                if (wasAllMatching) { location.reload(); return; }
                $('.ppd-select-row:checked').each(function() {
                    $(this).closest('tr').fadeOut(300, function() { $(this).remove(); });
                });
                if (typeof Swal !== 'undefined') {
                    Swal.fire('Deleted!', resp.message, 'success');
                }
            } else {
                alert(resp.error || 'Delete failed.');
            }
        }, 'json').fail(function() {
            alert('Server error.');
        });
    }

    // ── Form validation ──
    $(document).on('submit', '.ppg-form form', function(e) {
        var valid = true;
        $(this).find('[required]').each(function() {
            if (!$(this).val()) {
                $(this).css('border-color', '#dc3545');
                valid = false;
            } else {
                $(this).css('border-color', '');
            }
        });
        if (!valid) {
            e.preventDefault();
            if (typeof Swal !== 'undefined') {
                Swal.fire('Validation Error', 'Please fill in all required fields.', 'warning');
            } else {
                alert('Please fill in all required fields.');
            }
        }
    });

    // ── AJAX search with debounce ──
    var searchTimer;
    $(document).on('input', '.ppd-grid-search[data-autosearch]', function() {
        clearTimeout(searchTimer);
        var input = $(this);
        searchTimer = setTimeout(function() {
            input.closest('form').submit();
        }, 400);
    });

    /* ═══ Pages-module functionality: inline edit / copy / inline add / spreadsheet ═══ */
    var PP = (window.PP_BASE || '');
    function ppTable() { return document.body.getAttribute('data-pp-table') || ''; }
    function escAttr(v) { return String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }

    /* ═══ pp-nepcal: interactive AD / BS (English / Nepali) month calendar ═══ */
    (function () {
        var NEP_M = ['बैशाख','जेठ','असार','साउन','भदौ','असोज','कात्तिक','मंसिर','पुष','माघ','फागुन','चैत'];
        var NEP_D = ['आइत','सोम','मंगल','बुध','बिहि','शुक्र','शनि'];
        var EN_M = ['January','February','March','April','May','June','July','August','September','October','November','December'];
        var EN_D = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
        var NEP_DIG = '०१२३४५६७८९';
        function toNep(s) { return String(s).replace(/[0-9]/g, function (d) { return NEP_DIG[+d]; }); }
        function pad(n) { return (n < 10 ? '0' : '') + n; }
        function key(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
        function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }

        var tipEl = null;
        function tip() { if (!tipEl) { tipEl = document.createElement('div'); tipEl.className = 'pp-nc-tip'; tipEl.setAttribute('role', 'tooltip'); document.body.appendChild(tipEl); } return tipEl; }
        function showTip(cell, html) {
            var t = tip(); t.innerHTML = html; t.classList.add('is-on');
            var r = cell.getBoundingClientRect(), tr = t.getBoundingClientRect();
            var top = r.top + window.scrollY - tr.height - 9;
            var left = r.left + window.scrollX + r.width / 2 - tr.width / 2;
            if (top < window.scrollY + 4) { top = r.bottom + window.scrollY + 9; t.classList.add('below'); } else { t.classList.remove('below'); }
            left = Math.max(8, Math.min(left, window.scrollX + document.documentElement.clientWidth - tr.width - 8));
            t.style.top = top + 'px'; t.style.left = left + 'px';
        }
        function hideTip() { if (tipEl) tipEl.classList.remove('is-on'); }

        function ensureNF(cb) {
            if (window.NepaliFunctions) return cb(true);
            var s = document.createElement('script');
            s.src = (window.PP_BASE || '') + '/assets/vendor/nepali-datepicker/nepali.datepicker.min.js';
            s.onload = function () { cb(!!window.NepaliFunctions); };
            s.onerror = function () { cb(false); };
            document.head.appendChild(s);
        }

        function build(host) {
            var marks = {}, dataEl = host.querySelector('.pp-nc-data');
            try { marks = JSON.parse(dataEl ? dataEl.textContent : '{}') || {}; } catch (e) { marks = {}; }
            var type = host.getAttribute('data-nc-type') || 'english';
            var mode = (host.getAttribute('data-nc-default') === 'nepali') ? 'nepali' : 'english';
            if (type === 'english') mode = 'english'; if (type === 'nepali') mode = 'nepali';
            var accent = host.getAttribute('data-nc-accent') || '';
            var weekStart = host.getAttribute('data-nc-weekstart') === '1' ? 1 : 0;
            var todayHl = host.getAttribute('data-nc-today') !== '0';
            var linkTbl = host.getAttribute('data-nc-link') || '';
            var mount = host.querySelector('.pp-nc-mount');
            var panel = host.querySelector('.pp-nc-panel');
            if (!mount) return;
            if (accent) host.style.setProperty('--mcal-accent', accent);
            var now = new Date(), todayKey = key(now);
            var st = { ay: null, am: null, by: null, bm: null };
            var openKey = null;

            // Weekend shading — configurable in-app (persisted per browser), seeded
            // from the component's props. wkDays holds 0=Sun … 6=Sat.
            var wkKey = 'ppnc_wk_' + (host.getAttribute('data-nc-id') || 'cal');
            var wkShade = host.getAttribute('data-nc-shade') === '1';
            var wkDays = (host.getAttribute('data-nc-weekend') || '').split(',').filter(function (x) { return x !== ''; }).map(Number);
            var wkColor = host.getAttribute('data-nc-weekend-color') || '';
            try { var _wsv = JSON.parse(localStorage.getItem(wkKey) || 'null'); if (_wsv) { wkShade = !!_wsv.on; wkDays = _wsv.days || []; wkColor = _wsv.color || wkColor; } } catch (e) {}
            var cfgEl = null;
            function saveWk() { try { localStorage.setItem(wkKey, JSON.stringify({ on: wkShade, days: wkDays, color: wkColor })); } catch (e) {} }
            function closeCfg() { if (cfgEl) { cfgEl.remove(); cfgEl = null; } }
            function openCfg() {
                closeCfg();
                var isNep = mode === 'nepali', cur = wkShade ? wkDays.slice().sort(function (a, b) { return a - b; }).join(',') : 'off';
                function opt(v, l) { var on = (v === 'off') ? !wkShade : (wkShade && cur === v); return '<button type="button" data-wk="' + v + '" class="' + (on ? 'is-on' : '') + '">' + l + '</button>'; }
                var el = document.createElement('div'); el.className = 'pp-nc-cfg';
                el.innerHTML = '<div class="pp-nc-cfg-t"><i class="bi bi-calendar-week"></i> ' + (isNep ? 'सप्ताहन्त शेडिङ' : 'Weekend shading') + '</div>'
                    + '<div class="pp-nc-cfg-opts">' + opt('6', isNep ? 'शनि' : 'Sat') + opt('0,6', isNep ? 'शनि-आइत' : 'Sat–Sun') + opt('off', isNep ? 'बन्द' : 'Off') + '</div>'
                    + '<label class="pp-nc-cfg-color">' + (isNep ? 'रंग' : 'Colour') + '<input type="color" value="' + (wkColor || '#f59e0b') + '"></label>';
                host.appendChild(el); cfgEl = el;
            }

            function dateLabel(adKey) {
                var p = adKey.split('-'), ad = new Date(+p[0], +p[1] - 1, +p[2]);
                if (mode === 'nepali' && window.NepaliFunctions) {
                    var b = window.NepaliFunctions.AD2BS({ year: +p[0], month: +p[1], day: +p[2] });
                    return NEP_D[ad.getDay()] + ', ' + NEP_M[b.month - 1] + ' ' + toNep(b.day) + ', ' + toNep(b.year);
                }
                return EN_D[ad.getDay()] + ', ' + (+p[2]) + ' ' + EN_M[+p[1] - 1] + ' ' + p[0];
            }
            function openPanel(k) {
                var mk = marks[k]; if (!mk || !mk.items) return;
                openKey = k; var isNep = mode === 'nepali';
                var h = '<div class="pp-nc-panel-h"><div class="pp-nc-panel-d"><strong>' + esc(dateLabel(k)) + '</strong>'
                    + '<span class="pp-nc-panel-c"><i class="bi bi-calendar2-check"></i> ' + (isNep ? toNep(mk.c) : mk.c) + ' ' + (isNep ? 'पेशी' : 'hearing(s)') + '</span></div>'
                    + '<button type="button" class="pp-nc-close" data-nc-close aria-label="Close">&times;</button></div><div class="pp-nc-panel-list">';
                mk.items.forEach(function (it) {
                    h += '<div class="pp-nc-ev"><div class="pp-nc-ev-t"><i class="bi bi-folder2-open"></i> ' + esc(it.t || '—') + '</div>';
                    if (it.f && it.f.length) { h += '<div class="pp-nc-ev-f">'; it.f.forEach(function (kv) { if (kv[1]) h += '<div><span>' + esc(kv[0]) + '</span>' + esc(kv[1]) + '</div>'; }); h += '</div>'; }
                    if (linkTbl && it.id) h += '<a class="pp-nc-ev-open" href="' + (window.PP_BASE || '') + '/generated/' + linkTbl + '/view.php?id=' + encodeURIComponent(it.id) + '"><i class="bi bi-box-arrow-up-right"></i> ' + (isNep ? 'खोल्नुहोस्' : 'Open') + '</a>';
                    h += '</div>';
                });
                panel.innerHTML = h + '</div>'; panel.hidden = false; host.classList.add('is-split'); hideTip(); draw();
            }
            function closePanel() { openKey = null; panel.hidden = true; panel.innerHTML = ''; host.classList.remove('is-split'); draw(); }

            function draw() {
                if (mode === 'nepali' && !window.NepaliFunctions) { ensureNF(function (ok) { if (!ok) mode = 'english'; draw(); }); return; }
                var isNep = mode === 'nepali', y, m, firstAd, len, monLabel, yrLabel;
                if (isNep) {
                    if (st.by == null) { var tb = window.NepaliFunctions.AD2BS({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() }); st.by = tb.year; st.bm = tb.month; }
                    y = st.by; m = st.bm;
                    var a1 = window.NepaliFunctions.BS2AD({ year: y, month: m, day: 1 });
                    firstAd = new Date(a1.year, a1.month - 1, a1.day);
                    var nx = m === 12 ? { y: y + 1, mo: 1 } : { y: y, mo: m + 1 };
                    var aN = window.NepaliFunctions.BS2AD({ year: nx.y, month: nx.mo, day: 1 });
                    len = Math.round((new Date(aN.year, aN.month - 1, aN.day) - firstAd) / 86400000);
                    monLabel = NEP_M[m - 1]; yrLabel = toNep(y);
                } else {
                    if (st.ay == null) { st.ay = now.getFullYear(); st.am = now.getMonth() + 1; }
                    y = st.ay; m = st.am; firstAd = new Date(y, m - 1, 1); len = new Date(y, m, 0).getDate();
                    monLabel = EN_M[m - 1]; yrLabel = String(y);
                }
                if (wkShade && wkColor) host.style.setProperty('--mcal-weekend', wkColor); else host.style.removeProperty('--mcal-weekend');
                var dows = isNep ? NEP_D : EN_D, off = (firstAd.getDay() - weekStart + 7) % 7;
                var h = '<div class="pp-nc-bar"><button type="button" class="pp-nc-nav" data-nc-prev aria-label="Previous">‹</button>'
                    + '<div class="pp-nc-my"><strong>' + monLabel + '</strong> <span>' + yrLabel + '</span></div>'
                    + '<button type="button" class="pp-nc-nav" data-nc-next aria-label="Next">›</button>';
                if (type === 'both') h += '<div class="pp-nc-toggle"><button type="button" data-nc-mode="english" class="' + (isNep ? '' : 'is-on') + '">AD</button><button type="button" data-nc-mode="nepali" class="' + (isNep ? 'is-on' : '') + '">ने</button></div>';
                h += '<button type="button" class="pp-nc-gear" data-nc-gear aria-label="Weekend settings"><i class="bi bi-gear"></i></button>';
                h += '</div><div class="pp-nc-wd">';
                for (var w = 0; w < 7; w++) { var _wd = (w + weekStart) % 7; h += '<span' + ((wkShade && wkDays.indexOf(_wd) >= 0) ? ' class="is-weekend"' : '') + '>' + dows[_wd] + '</span>'; }
                h += '</div><div class="pp-nc-grid">';
                for (var b = 0; b < off; b++) h += '<span class="pp-nc-cell is-blank"></span>';
                for (var d = 1; d <= len; d++) {
                    var ad = new Date(firstAd.getFullYear(), firstAd.getMonth(), firstAd.getDate() + (d - 1)), k = key(ad), mk = marks[k];
                    var cls = 'pp-nc-cell'; if (todayHl && k === todayKey) cls += ' is-today'; if (mk && mk.c) cls += ' has-rec'; if (k === openKey) cls += ' is-sel';
                    if (wkShade && wkDays.indexOf(ad.getDay()) >= 0) cls += ' is-weekend';
                    var num = '<span class="pp-nc-daynum">' + (isNep ? toNep(d) : d) + '</span>';
                    var badge = '', chips = '', tipAttr = '', dataK = '';
                    if (mk && mk.c) {
                        dataK = ' data-nc-key="' + k + '"';
                        badge = '<span class="pp-nc-badge">' + (isNep ? toNep(mk.c) : mk.c) + '</span>';
                        var its = mk.items || [];
                        var shown = its.slice(0, 2).map(function (it) { return '<span class="pp-nc-chip">' + esc(it.t || '') + '</span>'; }).join('');
                        if (mk.c > 2) shown += '<span class="pp-nc-more">+' + (isNep ? toNep(mk.c - 2) : (mk.c - 2)) + (isNep ? ' थप' : ' more') + '</span>';
                        chips = '<span class="pp-nc-chips">' + shown + '</span>';
                        var lines = its.slice(0, 5).map(function (it) { return '<div class="pp-nc-tl">• ' + esc(it.t) + '</div>'; }).join('');
                        tipAttr = ' data-tip="' + esc('<div class="pp-nc-th">' + (isNep ? toNep(mk.c) : mk.c) + ' ' + (isNep ? 'पेशी' : 'hearing(s)') + '</div>' + lines) + '"';
                    }
                    h += '<span class="' + cls + '"' + dataK + tipAttr + '>' + num + badge + chips + '</span>';
                }
                mount.innerHTML = h + '</div>';
            }
            host.addEventListener('click', function (e) {
                if (e.target.closest && e.target.closest('[data-nc-close]')) { closePanel(); return; }
                if (e.target.closest && e.target.closest('.pp-nc-ev-open')) return; // let the link navigate
                if (e.target.closest) {
                    if (e.target.closest('[data-nc-gear]')) { if (cfgEl) closeCfg(); else openCfg(); return; }
                    var wkBtn = e.target.closest('.pp-nc-cfg [data-wk]');
                    if (wkBtn) {
                        var v = wkBtn.getAttribute('data-wk');
                        if (v === 'off') { wkShade = false; } else { wkShade = true; wkDays = v.split(',').map(Number); }
                        saveWk(); draw();
                        if (cfgEl) { var cur = wkShade ? wkDays.slice().sort(function (a, b) { return a - b; }).join(',') : 'off'; var obs = cfgEl.querySelectorAll('[data-wk]'); for (var oi = 0; oi < obs.length; oi++) { var bv = obs[oi].getAttribute('data-wk'); obs[oi].classList.toggle('is-on', bv === 'off' ? !wkShade : (wkShade && cur === bv)); } }
                        return;
                    }
                    if (e.target.closest('.pp-nc-cfg')) return; // stay open for the colour picker
                }
                closeCfg();
                var t = e.target.closest ? e.target.closest('button') : null;
                if (t && host.contains(t)) {
                    if (t.hasAttribute('data-nc-prev') || t.hasAttribute('data-nc-next')) {
                        var dir = t.hasAttribute('data-nc-next') ? 1 : -1;
                        if (mode === 'nepali') { st.bm += dir; if (st.bm < 1) { st.bm = 12; st.by--; } else if (st.bm > 12) { st.bm = 1; st.by++; } }
                        else { st.am += dir; if (st.am < 1) { st.am = 12; st.ay--; } else if (st.am > 12) { st.am = 1; st.ay++; } }
                        draw(); return;
                    }
                    if (t.hasAttribute('data-nc-mode')) { mode = t.getAttribute('data-nc-mode'); st = { ay: null, am: null, by: null, bm: null }; if (openKey) closePanel(); else draw(); return; }
                }
                var cell = e.target.closest ? e.target.closest('.pp-nc-cell[data-nc-key]') : null;
                if (cell && host.contains(cell)) { var ck = cell.getAttribute('data-nc-key'); if (ck === openKey) closePanel(); else openPanel(ck); }
            });
            host.addEventListener('mouseover', function (e) { if (host.classList.contains('is-split')) return; var c = e.target.closest ? e.target.closest('.pp-nc-cell[data-tip]') : null; if (c) showTip(c, c.getAttribute('data-tip')); });
            host.addEventListener('mouseout', function (e) { var c = e.target.closest ? e.target.closest('.pp-nc-cell[data-tip]') : null; if (c) hideTip(); });
            host.addEventListener('input', function (e) { if (e.target && e.target.matches && e.target.matches('.pp-nc-cfg input[type=color]')) { wkColor = e.target.value; if (!wkShade) { wkShade = true; if (wkDays.length === 0) wkDays = [6]; } saveWk(); draw(); } });
            document.addEventListener('click', function (e) { if (cfgEl && !host.contains(e.target)) closeCfg(); });
            draw();
        }
        document.addEventListener('DOMContentLoaded', function () { var ls = document.querySelectorAll('.pp-nepcal'); for (var i = 0; i < ls.length; i++) build(ls[i]); });
    })();

    /* ═══ pp-ai-assist: Claude-powered record assistant ═══ */
    (function () {
        function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
        function md(text) {
            var h = esc(text)
                .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
                .replace(/^\s*#{1,4}\s+(.*)$/gm, '<h5>$1</h5>')
                .replace(/^\s*[-*•]\s+(.*)$/gm, '<li>$1</li>')
                .replace(/^\s*\d+\.\s+(.*)$/gm, '<li>$1</li>');
            h = h.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
            return '<p>' + h.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
        }
        function premiumHtml() {
            return '<div class="pp-ai-premium">'
                + '<span class="pp-ai-premium-badge"><i class="bi bi-gem-fill"></i> Get Premium</span>'
                + '<div class="pp-ai-premium-txt"><strong>AI कानूनी सहायक</strong> — मुद्दाको सारांश, अर्को कदमको सुझाव र सूचना मस्यौदा, सबै AI बाट। '
                + 'सक्रिय गर्न आफ्नो मनपर्ने AI प्रदायक जडान गर्नुहोस्। <span class="pp-ai-premium-sub">Unlock AI summaries, next-step suggestions & notice drafting — connect your preferred AI provider.</span></div>'
                + '</div>';
        }
        function wire(host) {
            var table = host.getAttribute('data-ai-table');
            var pick = host.querySelector('.pp-ai-pick');
            var out = host.querySelector('.pp-ai-out');
            var help = host.querySelector('.pp-ai-help');
            var info = host.querySelector('.pp-ai-info');
            var infoT;
            // "?" reveals the provider-neutral setup hint, then tucks it away again.
            if (help && info) {
                help.addEventListener('click', function () {
                    info.hidden = !info.hidden;
                    clearTimeout(infoT);
                    if (!info.hidden) infoT = setTimeout(function () { info.hidden = true; }, 8000);
                });
            }
            // Transient notices (not configured / errors / hints) fade after a moment;
            // real answers stay until the next request.
            function flash(html) { out.innerHTML = html; clearTimeout(out._t); out._t = setTimeout(function () { out.innerHTML = ''; }, 6000); }
            // Premium gating: when AI isn't configured/entitled, lock the task buttons
            // with a "Get Premium" hint (the server also enforces this).
            var aiEntitled = !window.PP_ENTITLE || window.PP_ENTITLE.ai !== false;
            if (!aiEntitled) { var tb = host.querySelectorAll('.pp-ai-task'); for (var k = 0; k < tb.length; k++) { tb[k].classList.add('pp-dg-locked'); tb[k].setAttribute('title', 'Get Premium — ask your administrator to enable AI'); } }
            host.addEventListener('click', function (e) {
                var b = e.target.closest ? e.target.closest('.pp-ai-task') : null;
                if (!b || !host.contains(b)) return;
                if (!aiEntitled) { flash(premiumHtml()); return; }
                var id = pick && pick.value;
                if (!id) { flash('<div class="pp-ai-hint">पहिले एउटा रेकर्ड छान्नुहोस् / Choose a record first.</div>'); return; }
                var task = b.getAttribute('data-ai-task');
                var btns = host.querySelectorAll('.pp-ai-task');
                for (var i = 0; i < btns.length; i++) btns[i].disabled = true;
                clearTimeout(out._t);
                out.innerHTML = '<div class="pp-ai-loading"><span class="pp-ai-spin"></span> AI सोच्दैछ… / thinking…</div>';
                fetch((window.PP_BASE || '') + '/api/ai-assist.php', {
                    method: 'POST', credentials: 'same-origin',
                    headers: { 'content-type': 'application/json' },
                    body: JSON.stringify({ table: table, id: id, task: task })
                }).then(function (r) { return r.json(); }).then(function (d) {
                    if (d && d.success) { out.innerHTML = '<div class="pp-ai-result">' + md(d.text) + '</div>' + (d.model ? '<div class="pp-ai-model"><i class="bi bi-stars"></i> ' + esc(d.model) + '</div>' : ''); }
                    else if (d && d.premium) { clearTimeout(out._t); out.innerHTML = premiumHtml(); }
                    else { flash('<div class="pp-ai-err">' + esc((d && d.error) || 'AI request failed.') + '</div>'); }
                }).catch(function () { flash('<div class="pp-ai-err">Network error.</div>'); })
                  .then(function () { var bb = host.querySelectorAll('.pp-ai-task'); for (var j = 0; j < bb.length; j++) bb[j].disabled = false; });
            });
        }
        document.addEventListener('DOMContentLoaded', function () { var ls = document.querySelectorAll('.pp-ai-assist'); for (var i = 0; i < ls.length; i++) wire(ls[i]); });
    })();

    /* ═══ pp-dg: document generator — merge a record into a {{token}} template,
       with BS/AD era dates (वि.सं. / इ.सं. prefixes), a chosen time, and print /
       PDF / text output ═══ */
    (function () {
        function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
        function toNep(s) { return String(s).replace(/[0-9]/g, function (d) { return '०१२३४५६७८९'[+d]; }); }
        function pad(x) { return (x < 10 ? '0' : '') + x; }
        function adToday() { var n = new Date(); return n.getFullYear() + '-' + pad(n.getMonth() + 1) + '-' + pad(n.getDate()); }
        function bsToday() {
            if (!window.NepaliFunctions || !window.NepaliFunctions.AD2BS) return '';
            try { var n = new Date(); var b = window.NepaliFunctions.AD2BS({ year: n.getFullYear(), month: n.getMonth() + 1, day: n.getDate() }); return toNep(b.year + '-' + pad(b.month) + '-' + pad(b.day)); } catch (e) { return ''; }
        }
        function ensureNF(cb) {
            if (window.NepaliFunctions) return cb();
            var s = document.createElement('script');
            s.src = (window.PP_BASE || '') + '/assets/vendor/nepali-datepicker/nepali.datepicker.min.js';
            s.onload = function () { cb(); }; s.onerror = function () { cb(); };
            document.head.appendChild(s);
        }
        function todayStr(sys, bsPre, adPre) {
            var bs = bsToday(), ad = adToday();
            if (sys === 'ad') return (adPre ? adPre + ' ' : '') + ad;
            if (sys === 'both') return (bsPre ? bsPre + ' ' : '') + (bs || ad) + ' (' + (adPre ? adPre + ' ' : '') + ad + ')';
            return (bsPre ? bsPre + ' ' : '') + (bs || ad);   // bs (falls back to AD if the lib is unavailable)
        }
        // Format the chosen HH:MM. 24h keeps HH:MM (Devanagari for BS). 12h writes
        // the Nepali day-part (बिहान/दिउँसो/बेलुका/राति) for BS, or AM/PM for AD.
        function fmtTime(t, sys, fmt) {
            t = t || '10:00';
            var mm = t.split(':'), h = parseInt(mm[0], 10) || 0, m = mm[1] || '00';
            if (fmt !== '12') return (sys === 'ad') ? t : toNep(t);
            var h12 = ((h + 11) % 12) + 1;
            if (sys === 'ad') return h12 + ':' + m + ' ' + (h < 12 ? 'AM' : 'PM');
            var part = h < 12 ? 'बिहान' : (h < 16 ? 'दिउँसो' : (h < 19 ? 'बेलुका' : 'राति'));
            return part + ' ' + toNep(h12 + ':' + m);
        }
        function merge(tpl, row, ctx) {
            return String(tpl).replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, function (m, k) {
                var lk = k.toLowerCase();
                if (lk === 'time') return ctx.time;
                if (lk === 'today' || lk === 'now') return ctx.today;
                if (lk === 'today_bs') return (ctx.bsPre ? ctx.bsPre + ' ' : '') + (bsToday() || adToday());
                if (lk === 'today_ad') return (ctx.adPre ? ctx.adPre + ' ' : '') + adToday();
                var v = (row && row[k] != null) ? String(row[k]) : '';
                // Any *_bs / *_ad field carries its era prefix — everywhere, per the
                // brief. BS dates are ALSO shown in Devanagari digits (Nepali).
                if (v) { if (/_bs$/i.test(k)) v = (ctx.bsPre ? ctx.bsPre + ' ' : '') + toNep(v); else if (/_ad$/i.test(k) && ctx.adPre) v = ctx.adPre + ' ' + v; }
                return v;
            });
        }
        function wire(host) {
            var dataEl = host.querySelector('.pp-dg-data');
            var payload = {}; try { payload = JSON.parse((dataEl && dataEl.textContent) || '{}'); } catch (e) {}
            var tpl = payload.tpl || '', rows = payload.rows || {};
            var lh = payload.lh || '', logo = payload.logo || '';
            var sys = host.getAttribute('data-dg-datesystem') || 'bs';
            var bsPre = host.getAttribute('data-dg-bsprefix') || '';
            var adPre = host.getAttribute('data-dg-adprefix') || '';
            var fmt = host.getAttribute('data-dg-timeformat') || '24';
            var lhAlign = host.getAttribute('data-dg-lhalign') || 'center';
            var pick = host.querySelector('.pp-dg-pick');
            var timeEl = host.querySelector('.pp-dg-time');
            var gen = host.querySelector('.pp-dg-gen');
            var printB = host.querySelector('.pp-dg-print');
            var pdfB = host.querySelector('.pp-dg-pdf');
            var dlB = host.querySelector('.pp-dg-download');
            var emailB = host.querySelector('.pp-dg-email');
            var smsB = host.querySelector('.pp-dg-sms');
            var sendMsg = host.querySelector('.pp-dg-sendmsg');
            var docId = host.getAttribute('data-dg-id') || '';
            var doc = host.querySelector('.pp-dg-doc');
            var emptyHtml = doc ? doc.innerHTML : '';
            var lastText = '', lastHtml = '', lastId = '', lastCtx = null;
            // Preload the BS converter so {{today}} renders instantly when in a Nepali era.
            if (sys === 'bs' || sys === 'both') ensureNF(function () {});
            function fileName() {
                var t = pick && pick.options[pick.selectedIndex] ? pick.options[pick.selectedIndex].text : 'document';
                return (t || 'document').replace(/[^\wऀ-ॿ-]+/g, '_').slice(0, 60) || 'document';
            }
            function letterheadHtml(ctx, row) {
                if (!lh && !logo) return '';
                var lhTxt = lh ? merge(lh, row, ctx) : '';
                return '<div class="pp-dg-letterhead" style="text-align:' + lhAlign + '">'
                    + (logo ? '<img class="pp-dg-lh-logo" src="' + esc(logo) + '" alt="">' : '')
                    + (lhTxt ? '<div class="pp-dg-lh-text">' + esc(lhTxt).replace(/\n/g, '<br>') + '</div>' : '') + '</div>';
            }
            function render() {
                var id = pick && pick.value;
                if (!id || !rows[id]) { if (doc) doc.innerHTML = emptyHtml;[printB, pdfB, dlB, emailB, smsB].forEach(function (b) { if (b) b.hidden = true; }); if (sendMsg) sendMsg.textContent = ''; return; }
                var t = (timeEl && timeEl.value) || '10:00';
                var ctx = { bsPre: bsPre, adPre: adPre, time: fmtTime(t, sys, fmt), today: todayStr(sys, bsPre, adPre) };
                lastId = id; lastCtx = ctx;
                lastText = merge(tpl, rows[id], ctx);
                lastHtml = letterheadHtml(ctx, rows[id]) + '<pre class="pp-dg-out">' + esc(lastText) + '</pre>';
                if (doc) doc.innerHTML = lastHtml;
                [printB, pdfB, dlB, emailB, smsB].forEach(function (b) { if (b) b.hidden = false; });
                if (sendMsg) sendMsg.textContent = '';
            }
            function send(channel, btn) {
                if (!lastId || !docId) return;
                if (sendMsg) { sendMsg.className = 'pp-dg-sendmsg is-wait'; sendMsg.textContent = 'Sending…'; }
                if (btn) btn.disabled = true;
                fetch((window.PP_BASE || '') + '/api/doc-send.php', {
                    method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
                    body: JSON.stringify({ docId: docId, id: lastId, channel: channel, today: (lastCtx && lastCtx.today) || '', time: (lastCtx && lastCtx.time) || '' })
                }).then(function (r) { return r.json(); }).then(function (d) {
                    if (sendMsg) { sendMsg.className = 'pp-dg-sendmsg ' + (d && d.success ? 'is-ok' : 'is-err'); sendMsg.textContent = (d && (d.message || d.error)) || (d && d.success ? 'Sent.' : 'Send failed.'); }
                }).catch(function () { if (sendMsg) { sendMsg.className = 'pp-dg-sendmsg is-err'; sendMsg.textContent = 'Network error.'; } })
                  .then(function () { if (btn) btn.disabled = false; });
            }
            // Premium gating: a channel that isn't configured (or the user isn't
            // entitled to) shows as locked with a "Get Premium" hint instead of sending.
            var ent = window.PP_ENTITLE || {};
            function gate(btn, ok) {
                if (!btn) return;
                if (ok) { btn.classList.remove('pp-dg-locked'); btn.removeAttribute('title'); }
                else { btn.classList.add('pp-dg-locked'); btn.setAttribute('title', 'Get Premium — ask your administrator to enable this'); }
            }
            gate(emailB, !!ent.email); gate(smsB, !!ent.sms);
            function guardedSend(channel, btn) {
                if (!ent[channel]) {
                    if (sendMsg) { sendMsg.className = 'pp-dg-sendmsg is-premium'; sendMsg.innerHTML = '<i class="bi bi-gem"></i> Get Premium — ' + channel.toUpperCase() + ' isn\'t enabled. Please contact your administrator.'; }
                    return;
                }
                send(channel, btn);
            }
            if (emailB) emailB.addEventListener('click', function () { guardedSend('email', emailB); });
            if (smsB) smsB.addEventListener('click', function () { guardedSend('sms', smsB); });
            function generate() { if ((sys === 'bs' || sys === 'both') && !window.NepaliFunctions) { ensureNF(render); } else { render(); } }
            if (gen) gen.addEventListener('click', generate);
            if (printB) printB.addEventListener('click', function () {
                var w = window.open('', '_blank'); if (!w) return;
                w.document.write('<style>body{font-family:"Noto Sans Devanagari",Inter,system-ui,sans-serif;color:#0f172a;margin:32px;line-height:1.7;font-size:14px;}'
                    + '.pp-dg-letterhead{margin-bottom:18px;padding-bottom:12px;border-bottom:2px solid #0f172a;}'
                    + '.pp-dg-lh-logo{max-height:64px;margin-bottom:8px;}.pp-dg-lh-text{font-weight:700;font-size:16px;line-height:1.4;}'
                    + '.pp-dg-out{white-space:pre-wrap;margin:0;font-family:inherit;}</style>' + lastHtml);
                w.document.close(); w.focus(); w.print();
            });
            if (pdfB) pdfB.addEventListener('click', function () {
                var out = doc && doc.querySelector('.pp-dg-out'); if (!out) return;
                if (window.PPpdf && window.PPpdf.fromElement) { pdfB.disabled = true; window.PPpdf.fromElement(doc, { filename: fileName(), background: '#ffffff' }).catch(function () {}).then(function () { pdfB.disabled = false; }); }
                else if (printB) printB.click();   // graceful fallback: print → "Save as PDF"
            });
            if (dlB) dlB.addEventListener('click', function () {
                var blob = new Blob([lastText], { type: 'text/plain;charset=utf-8' });
                var a = document.createElement('a'); a.href = URL.createObjectURL(blob);
                a.download = fileName() + '.txt'; document.body.appendChild(a); a.click();
                setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 100);
            });
        }
        document.addEventListener('DOMContentLoaded', function () { var ls = document.querySelectorAll('.pp-dg'); for (var i = 0; i < ls.length; i++) wire(ls[i]); });
    })();

    /* ═══ pp-nepdate: render any [data-nepdate] date as Nepali (Bikram Sambat, Devanagari) ═══
       Source era comes from data-era (ad|bs); the target from data-show (bs|ad|both,
       default bs). AD↔BS conversion uses the bundled NepaliFunctions (loaded on demand).
       Graceful: shows the raw value until the library resolves, never blank. */
    (function () {
        function toNep(s) { return String(s).replace(/[0-9]/g, function (d) { return '०१२३४५६७८९'[+d]; }); }
        function pad(x) { return (x < 10 ? '0' : '') + x; }
        function ensureNF(cb) {
            if (window.NepaliFunctions) return cb();
            var s = document.createElement('script'); s.src = (window.PP_BASE || '') + '/assets/vendor/nepali-datepicker/nepali.datepicker.min.js';
            s.onload = function () { cb(); }; s.onerror = function () { cb(); }; document.head.appendChild(s);
        }
        function parse(raw) { var m = String(raw).match(/(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/); return m ? { y: +m[1], m: +m[2], d: +m[3] } : null; }
        function render(el) {
            if (el._nd) return;
            var raw = (el.getAttribute('data-date') || el.textContent || '').trim();
            var srcEra = (el.getAttribute('data-era') || 'ad').toLowerCase();
            var show = (el.getAttribute('data-show') || 'bs').toLowerCase();
            var pre = el.getAttribute('data-pre') || '', adpre = el.getAttribute('data-adpre') || '';
            var p = parse(raw); if (!p) { el._nd = 1; return; }
            var adStr = p.y + '-' + pad(p.m) + '-' + pad(p.d), bsStr = adStr;
            try {
                if (srcEra === 'bs') { bsStr = adStr; if ((show === 'ad' || show === 'both') && window.NepaliFunctions) { var a = window.NepaliFunctions.BS2AD({ year: p.y, month: p.m, day: p.d }); adStr = a.year + '-' + pad(a.month) + '-' + pad(a.day); } }
                else { if (window.NepaliFunctions) { var b = window.NepaliFunctions.AD2BS({ year: p.y, month: p.m, day: p.d }); bsStr = b.year + '-' + pad(b.month) + '-' + pad(b.day); } }
            } catch (e) {}
            el._nd = 1;
            if (show === 'ad') el.textContent = (adpre ? adpre + ' ' : '') + adStr;
            else if (show === 'both') el.textContent = (pre ? pre + ' ' : '') + toNep(bsStr) + ' (' + (adpre ? adpre + ' ' : '') + adStr + ')';
            else el.textContent = (pre ? pre + ' ' : '') + toNep(bsStr);
        }
        window.__ppNepDates = function (root) {
            root = root || document;
            var els = [].slice.call(root.querySelectorAll('[data-nepdate]')).filter(function (el) { return !el._nd; });
            if (!els.length) return;
            var needLib = els.some(function (el) {
                var s = (el.getAttribute('data-era') || 'ad').toLowerCase(), sh = (el.getAttribute('data-show') || 'bs').toLowerCase();
                return (s !== 'bs' && sh !== 'ad') || (s === 'bs' && (sh === 'ad' || sh === 'both'));
            });
            if (needLib && !window.NepaliFunctions) ensureNF(function () { els.forEach(render); });
            else els.forEach(render);
        };
        document.addEventListener('DOMContentLoaded', function () { window.__ppNepDates(document); });
    })();

    /* ═══ pp-fc: court-fee calculator ═══ */
    (function () {
        function calc(host) {
            var claim = parseFloat(host.querySelector('.pp-fc-claim').value) || 0;
            var slabs = (host.getAttribute('data-fc-slabs') || '').split(',').map(function (s) { var p = s.split(':'); return { th: parseFloat(p[0]), rate: parseFloat(p[1]) || 0 }; });
            var rate = 0, matched = false;
            for (var i = 0; i < slabs.length; i++) { if (slabs[i].th > 0 && claim <= slabs[i].th) { rate = slabs[i].rate; matched = true; break; } }
            if (!matched) { for (var j = 0; j < slabs.length; j++) { if (!(slabs[j].th > 0)) { rate = slabs[j].rate; matched = true; } } if (!matched && slabs.length) rate = slabs[slabs.length - 1].rate; }
            var fee = claim * rate / 100;
            var min = parseFloat(host.getAttribute('data-fc-min')) || 0; if (fee < min) fee = min;
            var round = parseFloat(host.getAttribute('data-fc-round')) || 0; if (round > 0) fee = Math.ceil(fee / round) * round;
            host.querySelector('.pp-fc-fee').textContent = (host.getAttribute('data-fc-prefix') || '') + (Math.round(fee * 100) / 100).toLocaleString();
        }
        document.addEventListener('input', function (e) { var h = e.target.closest ? e.target.closest('.pp-fc') : null; if (h && e.target.classList.contains('pp-fc-claim')) calc(h); });
    })();

    /* ═══ pp-lc: limitation (statute) calculator — Nepali Date Picker (BS+AD) start ═══ */
    (function () {
        function toNep(s) { return String(s).replace(/[0-9]/g, function (d) { return '०१२३४५६७८९'[+d]; }); }
        function toAsc(s) { return String(s).replace(/[०-९]/g, function (d) { return String('०१२३४५६७८९'.indexOf(d)); }); }
        function pad(x) { return (x < 10 ? '0' : '') + x; }
        function ensureNF(cb) {
            if (window.NepaliFunctions) return cb();
            var base = (window.PP_BASE || ''), css = base + '/assets/vendor/nepali-datepicker/nepali.datepicker.min.css';
            if (!document.querySelector('link[data-ndp-css]')) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = css; l.setAttribute('data-ndp-css', '1'); document.head.appendChild(l); }
            var s = document.createElement('script'); s.src = base + '/assets/vendor/nepali-datepicker/nepali.datepicker.min.js';
            s.onload = function () { cb(); }; s.onerror = function () { cb(); }; document.head.appendChild(s);
        }
        function bs2ad(bsStr) { try { var p = window.NepaliFunctions.ParseDate(bsStr, 'YYYY-MM-DD'); if (!p || !p.parsedDate) return null; var a = window.NepaliFunctions.BS2AD(p.parsedDate); return new Date(a.year, a.month - 1, a.day); } catch (e) { return null; } }
        function ad2bsStr(d) { try { var a = window.NepaliFunctions.AD2BS({ year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }); return a.year + '-' + pad(a.month) + '-' + pad(a.day); } catch (e) { return ''; } }
        function calc(host) {
            var startEl = host.querySelector('.pp-lc-start');
            var start = toAsc((startEl && startEl.value) || '').replace(/[^\d-]/g, '');   // BS date, Arabic digits
            var type = host.querySelector('.pp-lc-type').value;
            var rules = {}, def = 35;
            (host.getAttribute('data-lc-rules') || '').split(',').forEach(function (r) { var p = r.split(':'); var k = (p[0] || '').trim(), v = parseInt(p[1], 10) || 0; if (k.toLowerCase() === 'default') def = v; else if (k) rules[k] = v; });
            var out = host.querySelector('.pp-lc-out'), dl = host.querySelector('.pp-lc-deadline'), left = host.querySelector('.pp-lc-left');
            if (!start) { dl.textContent = '—'; left.textContent = ''; out.className = 'pp-lc-out r-upcoming'; return; }
            function run() {
                var pre = host.getAttribute('data-lc-bsprefix') || 'वि.सं.';
                var days = rules[type] != null ? rules[type] : def;
                var adStart = window.NepaliFunctions ? bs2ad(start) : null;
                if (!adStart) {   // library missing → fall back to plain +days on the raw value, no era math
                    dl.textContent = '—'; left.textContent = ''; out.className = 'pp-lc-out r-upcoming'; return;
                }
                var d = new Date(adStart.getTime()); d.setDate(d.getDate() + days);
                var bsDeadline = ad2bsStr(d);
                dl.textContent = (pre ? pre + ' ' : '') + (bsDeadline ? toNep(bsDeadline) : toNep(d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())));
                var today = new Date(); today.setHours(0, 0, 0, 0);
                var diff = Math.round((d - today) / 86400000), warn = parseInt(host.getAttribute('data-lc-warn'), 10) || 30;
                var daysL = host.getAttribute('data-lc-days') || 'days', leftL = host.getAttribute('data-lc-left') || 'left', ovL = host.getAttribute('data-lc-overdue') || 'lapsed';
                out.className = 'pp-lc-out ' + (diff < 0 ? 'r-overdue' : (diff <= warn ? 'r-critical' : 'r-upcoming'));
                left.textContent = diff < 0 ? (ovL + ' ' + toNep(Math.abs(diff)) + ' ' + daysL) : (toNep(diff) + ' ' + daysL + ' ' + leftL);
            }
            if (!window.NepaliFunctions) ensureNF(run); else run();
        }
        function initPicker(host) {
            var el = host.querySelector('.pp-lc-start'); if (!el || el._ppLcInit || el._ppNdp) return;
            ensureNF(function () {
                if (!window.NepaliFunctions || typeof el.NepaliDatePicker !== 'function' || el._ppLcInit || el._ppNdp) return;
                el._ppLcInit = true;
                var opts = {}; try { opts = JSON.parse(el.getAttribute('data-pp-ndp') || '{}'); } catch (e) {}
                opts.onSelect = function (d) { if (d && d.value) el.value = d.value; el.dispatchEvent(new Event('change', { bubbles: true })); };
                try { el.NepaliDatePicker(opts); } catch (e) {}
            });
        }
        document.addEventListener('change', function (e) { var h = e.target.closest ? e.target.closest('.pp-lc') : null; if (h && (e.target.classList.contains('pp-lc-start') || e.target.classList.contains('pp-lc-type'))) calc(h); });
        document.addEventListener('DOMContentLoaded', function () { var ls = document.querySelectorAll('.pp-lc'); for (var i = 0; i < ls.length; i++) initPicker(ls[i]); });
    })();

    /* ═══ pp-dcl: daily cause list — filter the baked window by the chosen date ═══ */
    (function () {
        function esc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
        function render(host) {
            var dataEl = host.querySelector('.pp-dcl-data'); var payload = {}; try { payload = JSON.parse((dataEl && dataEl.textContent) || '{}'); } catch (e) {}
            var cfg = payload.cfg || {}, rows = payload.rows || [];
            var date = host.querySelector('.pp-dcl-date').value;
            var shown = host.querySelector('.pp-dcl-shown'); if (shown) shown.textContent = date;
            var body = host.querySelector('.pp-dcl-body'); if (!body) return;
            var todays = rows.filter(function (r) { return String(r.d).slice(0, 10) === date; });
            if (!todays.length) { body.innerHTML = '<div class="pp-cl-empty">' + esc(cfg.empty || 'No hearings on this date.') + '</div>'; return; }
            var groups = {}; todays.forEach(function (r) { (groups[r.g || '—'] = groups[r.g || '—'] || []).push(r); });
            var cols = cfg.cols || [], vs = cfg.vs || 'vs', html = '';
            Object.keys(groups).forEach(function (court) {
                var rs = groups[court];
                html += '<div class="pp-cl-court">' + esc(court) + ' <span>(' + rs.length + ')</span></div>';
                html += '<table class="pp-cl-t"><thead><tr>' + cols.map(function (c) { return '<th>' + esc(c[0]) + '</th>'; }).join('') + '</tr></thead><tbody>';
                rs.forEach(function (r, i) {
                    html += '<tr>' + cols.map(function (c) {
                        var key = c[1];
                        var dl = ' data-label="' + esc(c[0]) + '"';   // for the mobile stacked layout
                        if (key === 'serial') return '<td' + dl + '>' + (i + 1) + '</td>';
                        if (key === 'parties') return '<td' + dl + '>' + esc(r.a || '') + ' <span class="pp-cl-vs">' + esc(vs) + '</span> ' + esc(r.b || '') + '</td>';
                        if (key === '-') return '<td' + dl + '>—</td>';
                        return '<td' + dl + '>' + esc(r[key] || '') + '</td>';
                    }).join('') + '</tr>';
                });
                html += '</tbody></table>';
            });
            body.innerHTML = html;
        }
        document.addEventListener('change', function (e) { var h = e.target.closest ? e.target.closest('.pp-dcl') : null; if (h && e.target.classList.contains('pp-dcl-date')) render(h); });
        document.addEventListener('DOMContentLoaded', function () { var ls = document.querySelectorAll('.pp-dcl'); for (var i = 0; i < ls.length; i++) render(ls[i]); });
    })();

    /* ═══ pp-pl: client portal link — build + copy the signed public URL ═══ */
    (function () {
        // A portal link is sent to an outside party, so it must be ABSOLUTE (scheme+host),
        // not the root-relative path baked at build time. location.origin resolves to
        // whatever host the app is actually served from — localhost in dev, the real
        // domain in production — so the copied/opened link is always correct.
        function abs(url) { if (!url) return ''; if (/^https?:\/\//i.test(url)) return url; return window.location.origin + (url.charAt(0) === '/' ? '' : '/') + url; }
        document.addEventListener('change', function (e) {
            var h = e.target.closest ? e.target.closest('.pp-pl') : null;
            if (!h || !e.target.classList.contains('pp-pl-pick')) return;
            var url = abs(e.target.value || ''), inp = h.querySelector('.pp-pl-url'), open = h.querySelector('.pp-pl-open');
            if (inp) inp.value = url;
            if (open) { if (url) { open.href = url; open.hidden = false; } else open.hidden = true; }
        });
        document.addEventListener('click', function (e) {
            var b = e.target.closest ? e.target.closest('.pp-pl-copy') : null; if (!b) return;
            var inp = b.closest('.pp-pl').querySelector('.pp-pl-url');
            if (inp && inp.value) { inp.select(); try { navigator.clipboard.writeText(inp.value); } catch (x) { try { document.execCommand('copy'); } catch (y) {} } b.classList.add('is-copied'); setTimeout(function () { b.classList.remove('is-copied'); }, 1200); }
        });
    })();

    /* ═══ pp-cl: cause-list "Print" → clean print window ═══ */
    document.addEventListener('click', function (e) {
        var b = e.target.closest ? e.target.closest('.pp-cl-print') : null;
        if (!b) return;
        var doc = b.closest('.pp-cl') && b.closest('.pp-cl').querySelector('.pp-cl-doc');
        if (!doc) return;
        var css = '<style>body{font-family:"Noto Sans Devanagari",Inter,system-ui,sans-serif;color:#0f172a;margin:24px;}'
            + '.pp-cl-h{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid #0f172a;padding-bottom:8px;margin-bottom:14px;}'
            + '.pp-cl-h strong{font-size:18px;} .pp-cl-h span{color:#64748b;font-size:12px;}'
            + '.pp-cl-court{font-weight:800;margin:16px 0 6px;font-size:14px;} .pp-cl-court span{color:#64748b;font-weight:600;}'
            + '.pp-cl-t{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:10px;} '
            + '.pp-cl-t th,.pp-cl-t td{border:1px solid #cbd5e1;padding:6px 8px;text-align:left;vertical-align:top;} '
            + '.pp-cl-t th{background:#f1f5f9;font-weight:700;} .pp-cl-vs{color:#64748b;} .pp-cl-empty{color:#64748b;padding:20px;}</style>';
        var w = window.open('', '_blank', 'width=900,height=700');
        if (!w) return;
        w.document.write('<!doctype html><html><head><meta charset="utf-8"><title>Cause List</title>' + css + '</head><body>' + doc.innerHTML + '</body></html>');
        w.document.close();
        w.focus();
        setTimeout(function () { try { w.print(); } catch (x) {} }, 300);
    });

    // ── data-bound charts: fetch series + render dependency-free SVG ──
    document.addEventListener('DOMContentLoaded', function () {
        var PAL = ['#2563eb', '#0d9488', '#d97706', '#7c3aed', '#dc2626', '#0891b2', '#65a30d', '#db2777', '#64748b', '#ca8a04'];
        function svgEl(tag, attrs) {
            var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
            Object.keys(attrs || {}).forEach(function (k) { el.setAttribute(k, attrs[k]); });
            return el;
        }
        function renderChart(host, labels, values) {
            var type = host.getAttribute('data-pp-charttype') || 'bar';
            var H = parseInt(host.getAttribute('data-pp-chartheight'), 10) || 260;
            var color = host.getAttribute('data-pp-chartcolor') || PAL[0];
            var title = host.getAttribute('data-pp-charttitle') || '';
            var subtitle = host.getAttribute('data-pp-chartsubtitle') || '';
            var showValues = host.getAttribute('data-pp-chartshowvalues') === '1';
            var showGrid = host.getAttribute('data-pp-chartshowgrid') !== '0';
            var showLegend = host.getAttribute('data-pp-chartshowlegend') !== '0';
            var colorful = host.getAttribute('data-pp-chartcolorful') === '1';
            var smooth = host.getAttribute('data-pp-chartsmooth') === '1';
            var donutTotal = host.getAttribute('data-pp-chartdonuttotal') === '1';
            var animate = host.getAttribute('data-pp-chartanimate') !== '0';
            var depth = host.getAttribute('data-pp-chartdepth') === '1';
            var pfx = host.getAttribute('data-pp-chartprefix') || '';
            var sfx = host.getAttribute('data-pp-chartsuffix') || '';
            var abbrev = host.getAttribute('data-pp-chartabbrev') === '1';
            // Drill-down: click a bar/slice/point → the source table's list
            // page filtered to that group (identifiers whitelisted at build).
            var drillTable = host.getAttribute('data-pp-chartdrilltable') || '';
            var drillCol = host.getAttribute('data-pp-chartdrillcol') || '';
            var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            var uid = 'g' + Math.random().toString(36).slice(2, 8);
            var baseShadow = depth ? 'drop-shadow(0 2px 3px rgba(15,23,42,.18))' : '';
            function abbr(v) {
                var a = Math.abs(v);
                if (a >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
                if (a >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
                if (a >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
                return String(Math.round(v * 100) / 100);
            }
            function fmt(v) { return pfx + (abbrev ? abbr(v) : String(Math.round(v * 100) / 100)) + sfx; }
            function cOf(i) { return colorful ? PAL[i % PAL.length] : color; }
            // ── rich floating tooltip (progressive disclosure — follows the cursor) ──
            function tipEl() {
                var el = document.getElementById('pp-chart-tip');
                if (!el) {
                    el = document.createElement('div'); el.id = 'pp-chart-tip';
                    el.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;background:rgba(15,23,42,.95);color:#fff;font:600 12px/1.35 system-ui,-apple-system,sans-serif;padding:6px 10px;border-radius:8px;box-shadow:0 8px 24px rgba(2,6,23,.3);opacity:0;transition:opacity .12s ease;transform:translate(-50%,calc(-100% - 12px));white-space:nowrap;';
                    document.body.appendChild(el);
                }
                return el;
            }
            var marks = [];
            // one call = tooltip + coordinated highlight (dim the rest) + drill-down
            function interact(el, name, val, extra) {
                marks.push(el);
                el.setAttribute('role', 'img');
                el.setAttribute('aria-label', name + ': ' + fmt(val));   // screen-reader label (no duplicate native tooltip)
                var tip = tipEl();
                el.addEventListener('mousemove', function (e) {
                    tip.innerHTML = '<span style="opacity:.7">' + esc2(name) + '</span>&nbsp; <b>' + fmt(val) + '</b>' + (extra || '');
                    tip.style.left = e.clientX + 'px'; tip.style.top = e.clientY + 'px'; tip.style.opacity = '1';
                });
                el.addEventListener('mouseenter', function () {
                    marks.forEach(function (m) { if (m !== el) m.style.opacity = '0.3'; });
                    el.style.filter = (depth ? 'drop-shadow(0 4px 7px rgba(15,23,42,.28)) ' : '') + 'brightness(1.1)';
                });
                el.addEventListener('mouseleave', function () {
                    tip.style.opacity = '0';
                    marks.forEach(function (m) { m.style.opacity = ''; });
                    el.style.filter = baseShadow;
                });
                if (drillTable && drillCol) {
                    el.style.cursor = 'pointer';
                    el.addEventListener('click', function () {
                        location.href = (window.PP_BASE || '') + '/generated/' + drillTable + '/list.php?f[' +
                            encodeURIComponent(drillCol) + ']=' + encodeURIComponent(name);
                    });
                }
                if (baseShadow) el.style.filter = baseShadow;
            }
            function esc2(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
            // ── entry micro-animations via CSS keyframes (respect reduced-motion;
            // fill-mode:both guarantees the final full state even if rAF is throttled,
            // so a chart can never get stuck invisible) ──
            function ensureAnimStyles() {
                if (document.getElementById('pp-chart-anim')) return;
                var s = document.createElement('style'); s.id = 'pp-chart-anim';
                s.textContent = '@keyframes ppcGrowY{from{transform:scaleY(0)}to{transform:scaleY(1)}}'
                    + '@keyframes ppcGrowX{from{transform:scaleX(0)}to{transform:scaleX(1)}}'
                    + '@keyframes ppcFadeIn{from{opacity:0}to{opacity:1}}'
                    + '@keyframes ppcDraw{to{stroke-dashoffset:0}}';
                document.head.appendChild(s);
            }
            function animGrow(el, i, dir) {
                if (!animate || reduced) return;
                ensureAnimStyles();
                el.style.transformBox = 'fill-box';
                el.style.transformOrigin = dir === 'x' ? 'left center' : 'center bottom';
                el.style.animation = (dir === 'x' ? 'ppcGrowX' : 'ppcGrowY') + ' .55s cubic-bezier(.2,.85,.3,1) ' + (i * 45) + 'ms both';
            }
            function animFade(el, i) {
                if (!animate || reduced) return;
                ensureAnimStyles();
                el.style.animation = 'ppcFadeIn .45s ease ' + (i * 40) + 'ms both';
            }
            function animDraw(pathEl) {
                if (!animate || reduced) return;
                ensureAnimStyles();
                pathEl.style.strokeDasharray = '2600'; pathEl.style.strokeDashoffset = '2600';
                pathEl.style.animation = 'ppcDraw 1s ease forwards';
            }
            // vertical gradient fill for area (deeper when depth is on)
            function areaFill(svg) {
                var defs = svgEl('defs', {});
                var lg = svgEl('linearGradient', { id: uid + '-area', x1: '0', y1: '0', x2: '0', y2: '1' });
                lg.appendChild(svgEl('stop', { offset: '0', 'stop-color': color, 'stop-opacity': depth ? '.4' : '.24' }));
                lg.appendChild(svgEl('stop', { offset: '1', 'stop-color': color, 'stop-opacity': '.02' }));
                defs.appendChild(lg); svg.appendChild(defs);
                return 'url(#' + uid + '-area)';
            }
            function txtEl(str, tx, ty, anchor, size, fill) {
                var t = svgEl('text', { x: tx, y: ty, 'text-anchor': anchor || 'middle', 'font-size': size || 10, fill: fill || '#64748b' });
                t.textContent = str; return t;
            }
            host.innerHTML = '';
            if (title) {
                var t = document.createElement('div');
                t.style.cssText = 'font-weight:700;font-size:.95rem;margin-bottom:.15rem;';
                t.textContent = title; host.appendChild(t);
            }
            if (subtitle) {
                var st = document.createElement('div');
                st.style.cssText = 'color:var(--pp-muted,#94a3b8);font-size:.78rem;margin-bottom:.5rem;';
                st.textContent = subtitle; host.appendChild(st);
            }
            if (!labels.length) {
                var em = document.createElement('div');
                em.style.cssText = 'color:var(--pp-muted,#94a3b8);font-size:.85rem;padding:1rem 0;';
                em.textContent = 'No data.'; host.appendChild(em); return;
            }
            var W = 640;

            if (type === 'pie' || type === 'doughnut') {
                var cx = W / 2, cy = H / 2, r = Math.min(W, H) / 2 - 16;
                var total = values.reduce(function (a, b) { return a + b; }, 0) || 1;
                var svgP = svgEl('svg', { viewBox: '0 0 ' + W + ' ' + H, style: 'width:100%;height:auto;display:block;' });
                var a0 = -Math.PI / 2;
                values.forEach(function (v, i) {
                    var a1 = a0 + (v / total) * Math.PI * 2;
                    var large = (a1 - a0) > Math.PI ? 1 : 0;
                    var d = 'M ' + cx + ' ' + cy + ' L ' + (cx + r * Math.cos(a0)) + ' ' + (cy + r * Math.sin(a0))
                        + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + (cx + r * Math.cos(a1)) + ' ' + (cy + r * Math.sin(a1)) + ' Z';
                    var path = svgEl('path', { d: d, fill: PAL[i % PAL.length] });
                    svgP.appendChild(path);
                    interact(path, labels[i], v, ' &nbsp;(' + Math.round(v / total * 100) + '%)');
                    animFade(path, i);
                    if (showValues && (v / total) > 0.04) {
                        var am = (a0 + a1) / 2, lr = r * (type === 'doughnut' ? 0.78 : 0.6);
                        svgP.appendChild(txtEl(Math.round(v / total * 100) + '%', cx + lr * Math.cos(am), cy + lr * Math.sin(am) + 4, 'middle', 11, '#fff'));
                    }
                    a0 = a1;
                });
                if (type === 'doughnut') {
                    svgP.appendChild(svgEl('circle', { cx: cx, cy: cy, r: r * 0.55, fill: 'var(--pp-surface, #fff)' }));
                    if (donutTotal) {
                        svgP.appendChild(txtEl(fmt(total), cx, cy + 2, 'middle', 18, 'var(--pp-text,#0f172a)'));
                        svgP.appendChild(txtEl('Total', cx, cy + 20, 'middle', 10, '#94a3b8'));
                    }
                }
                host.appendChild(svgP);
                if (showLegend) {
                    var lg = document.createElement('div');
                    lg.style.cssText = 'display:flex;flex-wrap:wrap;gap:.35rem .9rem;margin-top:.5rem;font-size:.78rem;color:var(--pp-text,#475569);';
                    labels.forEach(function (l, i) {
                        var it = document.createElement('span');
                        it.innerHTML = '<i style="display:inline-block;width:9px;height:9px;border-radius:2px;background:' + PAL[i % PAL.length] + ';margin-right:.3rem;"></i>';
                        it.appendChild(document.createTextNode(l + ' (' + fmt(values[i]) + ')'));
                        if (drillTable && drillCol) {
                            it.style.cursor = 'pointer';
                            (function (lab) { it.addEventListener('click', function () { location.href = (window.PP_BASE || '') + '/generated/' + drillTable + '/list.php?f[' + encodeURIComponent(drillCol) + ']=' + encodeURIComponent(lab); }); })(l);
                        }
                        lg.appendChild(it);
                    });
                    host.appendChild(lg);
                }
                return;
            }

            var horiz = type === 'hbar';
            var pad = horiz ? { l: 96, r: 34, t: 8, b: 22 } : { l: 46, r: 12, t: 14, b: 26 };
            var svg = svgEl('svg', { viewBox: '0 0 ' + W + ' ' + H, style: 'width:100%;height:auto;display:block;' });
            var max = Math.max.apply(null, values.concat([1]));
            var iw = W - pad.l - pad.r, ih = H - pad.t - pad.b;

            if (horiz) {   // horizontal bars — great for long category labels
                if (showGrid) {
                    for (var hg = 0; hg <= 4; hg++) {
                        var hgx = pad.l + iw * hg / 4;
                        svg.appendChild(svgEl('line', { x1: hgx, y1: pad.t, x2: hgx, y2: pad.t + ih, stroke: '#e2e8f0', 'stroke-width': 1 }));
                        svg.appendChild(txtEl(abbrev ? abbr(max * hg / 4) : Math.round(max * hg / 4 * 100) / 100, hgx, H - 6, 'middle', 10, '#94a3b8'));
                    }
                }
                var bh = Math.max(6, ih / labels.length * 0.62);
                values.forEach(function (v, i) {
                    var by = pad.t + ih * (i + 0.5) / labels.length - bh / 2, bwv = Math.max(0, iw * v / max);
                    var rect = svgEl('rect', { x: pad.l, y: by, width: bwv, height: bh, rx: 3, fill: cOf(i) });
                    svg.appendChild(rect); interact(rect, labels[i], v); animGrow(rect, i, 'x');
                    svg.appendChild(txtEl(String(labels[i]).length > 14 ? String(labels[i]).slice(0, 13) + '…' : labels[i], pad.l - 6, by + bh / 2 + 4, 'end', 10, '#64748b'));
                    if (showValues) svg.appendChild(txtEl(fmt(v), pad.l + bwv + 4, by + bh / 2 + 4, 'start', 10, '#475569'));
                });
                host.appendChild(svg); return;
            }

            function x(i) { return pad.l + iw * (labels.length === 1 ? 0.5 : i / (labels.length - 1)); }
            function y(v) { return pad.t + ih * (1 - v / max); }
            if (showGrid) {
                for (var g = 0; g <= 4; g++) {
                    var gy = pad.t + ih * g / 4;
                    svg.appendChild(svgEl('line', { x1: pad.l, y1: gy, x2: W - pad.r, y2: gy, stroke: '#e2e8f0', 'stroke-width': 1 }));
                    svg.appendChild(txtEl(abbrev ? abbr(max * (1 - g / 4)) : Math.round(max * (1 - g / 4) * 100) / 100, pad.l - 6, gy + 4, 'end', 10, '#94a3b8'));
                }
            }
            if (type === 'bar') {
                var bw = Math.max(6, iw / labels.length * 0.6);
                values.forEach(function (v, i) {
                    var bx = pad.l + iw * (i + 0.5) / labels.length - bw / 2;
                    var rect = svgEl('rect', { x: bx, y: y(v), width: bw, height: pad.t + ih - y(v), rx: 3, fill: cOf(i) });
                    svg.appendChild(rect); interact(rect, labels[i], v); animGrow(rect, i, 'y');
                    if (showValues) svg.appendChild(txtEl(fmt(v), bx + bw / 2, y(v) - 4, 'middle', 10, '#475569'));
                });
            } else {   // line / area (optionally smooth)
                var pathD;
                if (smooth && values.length > 1) {
                    pathD = 'M ' + x(0) + ' ' + y(values[0]);
                    for (var si = 0; si < values.length - 1; si++) {
                        var sx0 = x(si), sy0 = y(values[si]), sx1 = x(si + 1), sy1 = y(values[si + 1]), sxm = (sx0 + sx1) / 2;
                        pathD += ' C ' + sxm + ' ' + sy0 + ' ' + sxm + ' ' + sy1 + ' ' + sx1 + ' ' + sy1;
                    }
                } else {
                    pathD = values.map(function (v, i) { return (i ? 'L' : 'M') + ' ' + x(i) + ' ' + y(v); }).join(' ');
                }
                if (type === 'area') {
                    svg.appendChild(svgEl('path', { d: pathD + ' L ' + x(values.length - 1) + ' ' + (pad.t + ih) + ' L ' + pad.l + ' ' + (pad.t + ih) + ' Z', fill: areaFill(svg), stroke: 'none' }));
                }
                var lineEl = svgEl('path', { d: pathD, fill: 'none', stroke: color, 'stroke-width': 2.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
                if (depth) lineEl.style.filter = 'drop-shadow(0 2px 3px rgba(15,23,42,.18))';
                svg.appendChild(lineEl); animDraw(lineEl);
                values.forEach(function (v, i) {
                    var dot = svgEl('circle', { cx: x(i), cy: y(v), r: 3.5, fill: colorful ? PAL[i % PAL.length] : color });
                    svg.appendChild(dot); interact(dot, labels[i], v); animFade(dot, i + 2);
                    if (showValues) svg.appendChild(txtEl(fmt(v), x(i), y(v) - 8, 'middle', 10, '#475569'));
                });
            }
            var step = Math.ceil(labels.length / 8);
            labels.forEach(function (l, i) {
                if (i % step !== 0) return;
                var lx = type === 'bar' ? pad.l + iw * (i + 0.5) / labels.length : x(i);
                svg.appendChild(txtEl(String(l).length > 12 ? String(l).slice(0, 11) + '…' : l, lx, H - 8, 'middle', 10, '#64748b'));
            });
            host.appendChild(svg);
        }
        Array.prototype.forEach.call(document.querySelectorAll('[data-pp-chart]'), function (host) {
            fetch((window.PP_BASE || '') + '/api/chart.php?id=' + encodeURIComponent(host.getAttribute('data-pp-chart')), { credentials: 'same-origin' })
                .then(function (r) { return r.json(); })
                .then(function (j) { renderChart(host, (j && j.labels) || [], (j && j.values) || []); })
                .catch(function () { renderChart(host, [], []); });
        });
    });

    // ── Lookup Wizard client machinery ──────────────────────────────
    // window.PP_LOOKUP_FIELDS = { field: { id: 'table.field', parent?: 'field' } }
    // Covers: cascading reloads, popup add/edit refresh, AJAX suggestion box.
    document.addEventListener('DOMContentLoaded', function () {
        var lkFields = window.PP_LOOKUP_FIELDS || {};

        function lkVal(name) {
            var f = document.getElementById('field_' + name) || document.querySelector('[name="' + name + '"]');
            return f ? (f.value == null ? '' : f.value) : '';
        }
        function lkFetch(cfg, extra) {
            var url = (window.PP_BASE || '') + '/api/lookup.php?id=' + encodeURIComponent(cfg.id) + (extra || '');
            if (cfg.parent) url += '&parent=' + encodeURIComponent(lkVal(cfg.parent));
            // Sibling form-field values the WHERE reads via :field.name
            (cfg.watch || []).forEach(function (name) {
                url += '&field[' + encodeURIComponent(name) + ']=' + encodeURIComponent(lkVal(name));
            });
            return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.json(); });
        }

        function lkReload(field) {
            var cfg = lkFields[field] || {};
            var el = document.getElementById('field_' + field);
            if (!el || el.tagName !== 'SELECT') return;
            var keep = el.multiple
                ? Array.prototype.map.call(el.selectedOptions, function (o) { return o.value; })
                : [el.value];
            var emptyLabel = !el.multiple && el.options.length && el.options[0].value === ''
                ? el.options[0].text : 'Choose…';
            el.disabled = true;
            lkFetch(cfg).then(function (j) {
                var opts = (j && j.options) || {};
                el.innerHTML = '';
                if (!el.multiple) {
                    var blank = document.createElement('option');
                    blank.value = ''; blank.textContent = emptyLabel;
                    el.appendChild(blank);
                }
                Object.keys(opts).forEach(function (k) {
                    var o = document.createElement('option');
                    o.value = k; o.textContent = opts[k];
                    if (keep.indexOf(String(k)) > -1) o.selected = true;
                    el.appendChild(o);
                });
                el.disabled = false;
                el.dispatchEvent(new Event('change', { bubbles: true }));   // chained cascades
            }).catch(function () { el.disabled = false; });
        }

        // cascades: parent / watched-field change reloads the dependent child
        Object.keys(lkFields).forEach(function (field) {
            var cfg = lkFields[field];
            var reload = function () { lkReload(field); };
            if (cfg.parent) {
                var parentEl = document.getElementById('field_' + cfg.parent)
                    || document.querySelector('[name="' + cfg.parent + '"]');
                if (parentEl) parentEl.addEventListener('change', reload);
            }
            // Any sibling field the WHERE reads via :field.name — re-filter live.
            (cfg.watch || []).forEach(function (name) {
                var wf = document.getElementById('field_' + name) || document.querySelector('[name="' + name + '"]');
                if (wf) { wf.addEventListener('change', reload); wf.addEventListener('input', reload); }
            });
            // Populate once from the current sibling values (correct on Edit; on a
            // fresh Add with empty siblings the WHERE simply matches nothing yet).
            if (cfg.watch && cfg.watch.length && cfg.watch.some(function (n) { return lkVal(n) !== ''; })) reload();
        });

        // popup add/edit ("values on the fly"): refresh every lookup on save
        window.addEventListener('message', function (e) {
            if (e && e.data && e.data.ppLookupSaved) {
                Object.keys(lkFields).forEach(function (field) { lkReload(field); });
            }
        });

        // AJAX suggestion box (edit box with popup): hidden key + visible text
        Array.prototype.forEach.call(document.querySelectorAll('[data-pp-lkajax]'), function (box) {
            var field = box.getAttribute('data-pp-lkajax');
            var free = box.getAttribute('data-pp-lkfree') === '1';
            var hidden = document.getElementById('field_' + field);
            var cfg = lkFields[field] || { id: box.getAttribute('data-pp-lkid') };
            if (!hidden || !cfg.id) return;
            var panel = document.createElement('div');
            panel.style.cssText = 'position:absolute;z-index:1000;min-width:220px;max-height:240px;overflow-y:auto;'
                + 'background:var(--pp-surface,#fff);border:1px solid var(--pp-border,#e2e8f0);border-radius:.5rem;box-shadow:var(--pp-shadow-md,0 10px 30px rgba(2,6,23,.15));display:none;';
            box.parentNode.style.position = 'relative';
            box.parentNode.appendChild(panel);
            var t = null;
            function hide() { panel.style.display = 'none'; }
            box.addEventListener('input', function () {
                if (free) hidden.value = box.value;   // free input stores the typed text
                clearTimeout(t);
                var q = box.value.trim();
                if (q === '') { hide(); if (!free) hidden.value = ''; return; }
                t = setTimeout(function () {
                    lkFetch(cfg, '&q=' + encodeURIComponent(q)).then(function (j) {
                        var opts = (j && j.options) || {};
                        panel.innerHTML = '';
                        var keys = Object.keys(opts);
                        if (!keys.length) { hide(); return; }
                        keys.forEach(function (k) {
                            var row = document.createElement('div');
                            row.textContent = opts[k];
                            row.style.cssText = 'padding:.45rem .7rem;cursor:pointer;font-size:.9rem;';
                            row.addEventListener('mouseenter', function () { row.style.background = '#eff6ff'; });
                            row.addEventListener('mouseleave', function () { row.style.background = ''; });
                            row.addEventListener('mousedown', function (ev) {
                                ev.preventDefault();
                                hidden.value = k; box.value = opts[k]; hide();
                                hidden.dispatchEvent(new Event('change', { bubbles: true }));
                            });
                            panel.appendChild(row);
                        });
                        panel.style.display = '';
                        panel.style.top = (box.offsetTop + box.offsetHeight + 4) + 'px';
                        panel.style.left = box.offsetLeft + 'px';
                    }).catch(hide);
                }, 220);
            });
            box.addEventListener('blur', function () { setTimeout(hide, 150); });
            box.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
        });
    });

    // ── copy record ──
    $(document).on('click', '.ppd-action-copy', function() {
        var id = $(this).data('id');
        var doCopy = function() {
            $.post(PP + '/api/copy.php', { table: ppTable(), id: id }, function(resp) {
                if (resp && resp.success) location.reload();
                else alert((resp && resp.error) || 'Copy failed');
            }, 'json');
        };
        if (typeof Swal !== 'undefined') {
            Swal.fire({ title: 'Copy this record?', icon: 'question', showCancelButton: true, confirmButtonText: 'Copy' })
                .then(function(r) { if (r.isConfirmed) doCopy(); });
        } else if (confirm('Copy this record?')) doCopy();
    });

    // A grid may be a nested Master–Detail child — its <table data-pp-table>
    // wins over the page's own table for inline saves.
    function ppRowTable(el) {
        var t = $(el).closest('table[data-pp-table]').attr('data-pp-table');
        return t || ppTable();
    }

    // After a child inline save, refresh the Master–Detail preview in place —
    // the expansion stays open and the master row's count badge stays true.
    // Returns false when the element is not inside a list-page expansion
    // (embedded view/edit grids just reload the page as before).
    function ppMdRefresh(el) {
        var exp = $(el).closest('tr.pp-md-exp');
        if (!exp.length) return false;
        var rels = (exp.attr('data-pp-md-for') || '').split(',').filter(function (x) { return x; });
        var key = exp.attr('data-pp-md-key') || '';
        if (!rels.length || key === '') return false;
        var td = exp.children('td').first();
        var html = '', left = rels.length;
        rels.forEach(function (rid) {
            fetch((window.PP_BASE || '') + '/api/details.php?rel=' + encodeURIComponent(rid) + '&key=' + encodeURIComponent(key), { credentials: 'same-origin' })
                .then(function (r) { return r.json(); })
                .then(function (j) {
                    html += (j && j.html) || '';
                    // sync this relation's count badge on the master row
                    var links = document.querySelectorAll('a.pp-md-link[data-pp-md-key="' + key + '"]');
                    Array.prototype.forEach.call(links, function (lk) {
                        var lkRels = (lk.getAttribute('data-pp-md-rel') || '').split(',');
                        if (lkRels.indexOf(rid) === -1) return;
                        var b = lk.querySelector('.badge');
                        if (b && j && typeof j.count !== 'undefined') b.textContent = j.count;
                    });
                })
                .catch(function () { })
                .then(function () {
                    if (--left === 0) td.html(html || '<span style="color:var(--pp-muted,#94a3b8);">No records.</span>');
                });
        });
        return true;
    }

    // Mount a field's REAL edit control (from PP_INLINE_EDITORS) into a cell so
    // inline edit / spreadsheet / inline add use the actual plugin (date pickers,
    // dropdowns, lookups…) not a plain input. Returns true when a control was
    // mounted. The control's value input is tagged .pp-inline-input[data-field]
    // so the existing save handlers pick it up unchanged.
    function ppMountEditor(container, field, raw) {
        var tpl = (window.PP_INLINE_EDITORS || {})[field];
        if (!tpl) return false;
        var el = container.jquery ? container[0] : container;
        el.innerHTML = tpl;
        var inp = el.querySelector('[name="' + field + '"]') || el.querySelector('[name="' + field + '[]"]') || el.querySelector('input,select,textarea');
        if (inp) {
            try {
                if (inp.tagName === 'SELECT' && inp.multiple) {
                    String(raw == null ? '' : raw).split(',').forEach(function (v) { v = v.trim(); for (var i = 0; i < inp.options.length; i++) if (inp.options[i].value === v) inp.options[i].selected = true; });
                } else { inp.value = (raw == null ? '' : raw); }
            } catch (e) {}
            inp.classList.add('pp-inline-input');
            inp.setAttribute('data-field', field);
        }
        // Re-execute injected scripts so self-enhancing controls define their init.
        el.querySelectorAll('script').forEach(function (old) { var s = document.createElement('script'); s.textContent = old.textContent; old.parentNode.replaceChild(s, old); });
        // Run enhancement inits scoped to the cell.
        if (window.ppInitPickers) window.ppInitPickers(el);
        if (window.__ppNdpInit) window.__ppNdpInit(el);
        if (window.__ppDdmsInit) window.__ppDdmsInit();
        if (window.__ppMselInit) window.__ppMselInit();
        return true;
    }
    function ppEditorValueEl(cell) {
        var el = cell.jquery ? cell[0] : cell;
        return el.querySelector('.pp-inline-input');
    }

    // ── inline edit (row → inputs → save via api/save.php) ──
    $(document).on('click', '.ppd-action-inline', function() {
        var tr = $(this).closest('tr');
        if (tr.data('editing')) return;
        tr.data('editing', 1).addClass('pp-editing');
        tr.find('td[data-field]').each(function() {
            var td = $(this);
            td.data('orig', td.html());
            var f = td.attr('data-field'), raw = td.attr('data-raw');
            if (ppMountEditor(td, f, raw)) return;   // real plugin control mounted
            var lk = (window.PP_LOOKUPS || {})[f];
            if ((window.PP_LOOKUPS_MULTI || []).indexOf(f) > -1) lk = null;   // CSV fields edit as text
            if (lk) {
                // Lookup field: edit via a dropdown of display names (stores the key).
                var opts = '<option value=""></option>';
                for (var k in lk) {
                    if (!Object.prototype.hasOwnProperty.call(lk, k)) continue;
                    opts += '<option value="' + escAttr(k) + '"' + (String(k) === String(raw) ? ' selected' : '') + '>' + escAttr(lk[k]) + '</option>';
                }
                td.html('<select class="pp-inline-input" data-field="' + escAttr(f) + '">' + opts + '</select>');
            } else {
                td.html('<input class="pp-inline-input" data-field="' + escAttr(f) + '" value="' + escAttr(raw) + '">');
            }
        });
        var act = tr.find('.ppd-grid-actions');
        act.data('orig', act.html());
        act.html('<button type="button" class="pp-inline-save" title="Save"><i class="bi bi-check-lg"></i></button>'
            + '<button type="button" class="pp-inline-cancel" title="Cancel"><i class="bi bi-x-lg"></i></button>');
    });
    $(document).on('click', '.pp-inline-cancel', function() {
        var tr = $(this).closest('tr');
        tr.find('td[data-field]').each(function() { $(this).html($(this).data('orig')); });
        var act = tr.find('.ppd-grid-actions');
        act.html(act.data('orig'));
        tr.data('editing', 0).removeClass('pp-editing');
    });
    $(document).on('click', '.pp-inline-save', function() {
        var tr = $(this).closest('tr');
        var payload = { table: ppRowTable(tr), id: tr.attr('data-id') };
        tr.find('.pp-inline-input').each(function() { payload[$(this).attr('data-field')] = $(this).val(); });
        $.post(PP + '/api/save.php', payload, function(resp) {
            if (resp && resp.success) { if (!ppMdRefresh(tr)) location.reload(); }
            else alert((resp && resp.error) || 'Save failed');
        }, 'json');
    });

    // Mount real plugin controls into the inline-add template row (once).
    function ppMountAddEditors(row) {
        if (row.data('editorsMounted')) return;
        row.find('.pp-inline-input[data-field]').each(function () {
            var inp = $(this), f = inp.attr('data-field');
            if ((window.PP_INLINE_EDITORS || {})[f]) ppMountEditor(inp.closest('td'), f, '');
        });
        row.data('editorsMounted', 1);
    }
    // ── inline add (toolbar button reveals the template row) ──
    $(document).on('click', '#ppInlineAdd', function() {
        var row = $('.pp-inline-add-row');
        if (!row.length) { location.href = 'add.php'; return; }
        row.show().prependTo(row.closest('tbody'));
        ppMountAddEditors(row);
        if (window.ppInitPickers) window.ppInitPickers(row[0]);   // enhance date/time inputs
        row.find('input,select,textarea').first().trigger('focus');
    });
    $(document).on('click', '.pp-inline-cancel-new', function() {
        var row = $(this).closest('tr');
        row.find('.pp-inline-input').each(function () { if (this._flatpickr) this._flatpickr.clear(); else this.value = ''; });
        row.hide();
    });
    // Master–Detail child previews: their own "+ Add" reveals the template row
    // scoped to that preview's table.
    $(document).on('click', '.pp-inline-add-btn', function() {
        var wrap = $(this).closest('.pp-md-preview, .pp-detail-grid');
        var row = wrap.find('.pp-inline-add-row');
        if (!row.length) return;
        row.show().prependTo(row.closest('tbody'));
        ppMountAddEditors(row);
        if (window.ppInitPickers) window.ppInitPickers(row[0]);   // enhance date/time inputs
        row.find('input,select,textarea').first().trigger('focus');
    });
    $(document).on('click', '.pp-inline-save-new', function() {
        var row = $(this).closest('tr');
        var payload = { table: ppRowTable(row), id: 0 };
        row.find('.pp-inline-input').each(function() { payload[$(this).attr('data-field')] = $(this).val(); });
        // child preview rows inherit the master FK automatically
        var fkWrap = row.closest('[data-pp-fk]');
        if (fkWrap.length) payload[fkWrap.attr('data-pp-fk')] = fkWrap.attr('data-pp-fkval');
        $.post(PP + '/api/save.php', payload, function(resp) {
            if (resp && resp.success) { if (!ppMdRefresh(row)) location.reload(); }
            else alert((resp && resp.error) || 'Save failed');
        }, 'json');
    });

    // ── spreadsheet mode: click any editable cell, autosave on blur/Enter ──
    $(document).on('click', '.ppd-grid-table.pp-spreadsheet td[data-field]', function() {
        var td = $(this);
        if (td.find('.pp-inline-input').length || td.closest('tr').data('editing')) return;
        // Plugin controls (popups) don't blur-close, so restore any OTHER cell
        // still in edit mode — only one spreadsheet cell edits at a time.
        $('.ppd-grid-table.pp-spreadsheet td[data-field]').each(function() {
            var o = $(this);
            if (o[0] !== td[0] && o.find('.pp-inline-input').length && o.data('orig') != null) { o.html(o.data('orig')); o.removeData('orig'); }
        });
        var raw = td.attr('data-raw') || '';
        td.data('orig', td.html());
        var mounted = ppMountEditor(td, td.attr('data-field'), raw);
        if (!mounted) td.html('<input class="pp-inline-input pp-cell-input" value="' + escAttr(raw) + '">');
        var input = $(ppEditorValueEl(td) || td[0]);
        input.trigger('focus');
        var done = false;
        // Nepali Date Picker double-mode pairing: whichever cell is edited (BS or
        // AD), convert to the OTHER calendar, add it to the save, and refresh its
        // cell — the paired field itself isn't in edit mode here. The AD side uses
        // its flatpickr Date object so the display format never matters.
        function pairedSave(field, valEl, payload) {
            var pair = (window.PP_NDP_PAIRS || {})[field];
            if (!pair || typeof NepaliFunctions === 'undefined') return;
            try {
                var isBs = (field === pair.bs), srcObj = null;
                if (isBs) { var pd = NepaliFunctions.ParseDate(valEl.value, pair.bsFmt); srcObj = pd && pd.parsedDate; }
                else if (valEl._flatpickr && valEl._flatpickr.selectedDates && valEl._flatpickr.selectedDates[0]) {
                    var dt = valEl._flatpickr.selectedDates[0]; srcObj = { year: dt.getFullYear(), month: dt.getMonth() + 1, day: dt.getDate() };
                } else { var pd2 = NepaliFunctions.ParseDate(valEl.value, pair.adFmt); srcObj = pd2 && pd2.parsedDate; }
                if (!srcObj) return;
                var dstField = isBs ? pair.ad : pair.bs;
                var dstFmt = isBs ? pair.adFmt : pair.bsFmt;
                var out = NepaliFunctions.ConvertToDateFormat(isBs ? NepaliFunctions.BS2AD(srcObj) : NepaliFunctions.AD2BS(srcObj), dstFmt);
                if (out) {
                    payload[dstField] = out;
                    var cell = td.closest('tr').find('td[data-field="' + dstField + '"]');
                    if (cell.length) cell.attr('data-raw', out).text(out);
                }
            } catch (e) {}
        }
        var finish = function(save) {
            if (done) return; done = true;
            var v = input.val();
            if (!save || v === raw) { td.html(td.data('orig')); return; }
            var payload = { table: ppRowTable(td), id: td.closest('tr').attr('data-id') };
            payload[td.attr('data-field')] = v;
            pairedSave(td.attr('data-field'), input[0], payload);
            $.post(PP + '/api/save.php', payload, function(resp) {
                if (resp && resp.success) { td.attr('data-raw', v).text(v); }
                else { td.html(td.data('orig')); alert((resp && resp.error) || 'Save failed'); }
            }, 'json');
        };
        if (mounted) {
            // Plugin controls have popups (calendars/menus) — blur fires when they
            // open, so save on the control's own change event instead.
            input.on('change', function() { done = false; finish(true); });
            td.on('keydown', function(e) { if (e.key === 'Escape') finish(false); });
        } else {
            input.on('blur', function() { finish(true); });
            input.on('keydown', function(e) {
                if (e.key === 'Enter') { e.preventDefault(); input.off('blur'); finish(true); }
                if (e.key === 'Escape') { input.off('blur'); finish(false); }
            });
        }
    });

    // ── Tabs (component) — switch panels ──
    $(document).on('click', '.ppd-c-tabs [data-pptab]', function() {
        var i = $(this).attr('data-pptab');
        var box = $(this).closest('.ppd-c-tabs');
        box.find('> .ppd-tabbar > [data-pptab]').removeClass('active');
        box.find('> .ppd-tabbar > [data-pptab="' + i + '"]').addClass('active');
        box.find('> [data-pptabpane]').removeClass('active').attr('hidden', 'hidden');
        box.find('> [data-pptabpane="' + i + '"]').addClass('active').removeAttr('hidden');
    });

    // ── Accordion (component) — toggle body ──
    $(document).on('click', '.ppd-accordion [data-ppacc-toggle]', function() {
        var acc = $(this).closest('.ppd-accordion');
        var open = !acc.hasClass('is-open');
        acc.toggleClass('is-open', open);
        acc.find('> [data-ppacc-body]').attr('hidden', open ? null : 'hidden');
        $(this).find('.ppd-acc-caret').attr('class', 'bi ' + (open ? 'bi-chevron-down' : 'bi-chevron-right') + ' ppd-acc-caret');
    });

})(jQuery);

// ── Master–Detail: list-page details preview (inline expand / popup) ──
// Links come from renderTable's details column; content is fetched from
// api/details.php (id-whitelisted relation registry).
document.addEventListener('click', function (e) {
    var a = e.target.closest('a.pp-md-link[data-pp-md-rel]');
    if (!a) return;
    e.preventDefault();
    var rels = (a.getAttribute('data-pp-md-rel') || '').split(',').filter(Boolean);
    var key = a.getAttribute('data-pp-md-key') || '';
    var mode = a.getAttribute('data-pp-md-mode') || 'inline';
    function fetchAll(cb) {
        var html = '', left = rels.length;
        if (!left) return cb('');
        rels.forEach(function (rid) {
            fetch((window.PP_BASE || '') + '/api/details.php?rel=' + encodeURIComponent(rid) + '&key=' + encodeURIComponent(key), { credentials: 'same-origin' })
                .then(function (r) { return r.json(); })
                .then(function (j) { html += (j && j.html) || ''; })
                .catch(function () { })
                .then(function () { if (--left === 0) cb(html); });
        });
    }
    if (mode === 'popup') {
        fetchAll(function (html) {
            if (window.Swal) Swal.fire({ html: html || '<i>No records.</i>', width: '72rem', showConfirmButton: false, showCloseButton: true });
            else alert('No preview available.');
        });
        return;
    }
    // inline: toggle an expansion row directly under this record
    var tr = a.closest('tr');
    if (!tr) return;
    var caret = a.querySelector('.pp-md-caret');
    var next = tr.nextElementSibling;
    if (next && next.classList.contains('pp-md-exp')) {
        var mine = next.getAttribute('data-pp-md-for') === a.getAttribute('data-pp-md-rel');
        next.remove();
        if (caret) caret.style.transform = '';
        if (mine) return;   // second click on the same link = collapse
    }
    var exp = document.createElement('tr');
    exp.className = 'pp-md-exp';
    exp.setAttribute('data-pp-md-for', a.getAttribute('data-pp-md-rel'));
    exp.setAttribute('data-pp-md-key', key);
    var td = document.createElement('td');
    td.colSpan = tr.children.length;
    td.style.cssText = 'background:var(--pp-surface-soft,#f8fafc);color:var(--pp-text,#0f172a);padding:10px 14px;';
    td.innerHTML = '<i class="bi bi-hourglass-split"></i> Loading…';
    exp.appendChild(td);
    tr.parentNode.insertBefore(exp, tr.nextSibling);
    if (caret) caret.style.transform = 'rotate(90deg)';
    fetchAll(function (html) {
        td.innerHTML = html || '<span style="color:var(--pp-muted,#94a3b8);">No records.</span>';
    });
});

// ── Cards view (Pages module 'cards' page-type) ──────────────────────────
// Builds a card per row from the already-rendered grid (so plugin view
// controls, lookups and badges carry over), toggled from the toolbar and
// remembered per table. Table remains the source of truth.
(function () {
    function buildCards(table, mount) {
        // Server-rendered cards (Card Template) are already in the DOM — leave them.
        if (mount && mount.getAttribute('data-pp-server') === '1') return;
        if (!table || !mount || mount.getAttribute('data-built') === '1') return;
        var heads = Array.prototype.slice.call(table.querySelectorAll('thead th'));
        var cols = heads.map(function (th, i) {
            var cls = th.className || '', kind = 'data';
            if (/ppd-grid-select/.test(cls)) kind = 'select';
            else if (/ppd-grid-actions/.test(cls)) kind = 'actions';
            else if (/ppd-grid-details/.test(cls)) kind = 'details';
            return { i: i, kind: kind, label: (th.textContent || '').trim() };
        });
        var rows = Array.prototype.slice.call(table.querySelectorAll('tbody > tr'));
        var frag = document.createDocumentFragment();
        rows.forEach(function (tr) {
            if (tr.classList.contains('pp-md-exp')) return;         // detail expansion row
            if (tr.classList.contains('pp-inline-add-row')) return; // hidden inline-add template
            if (tr.offsetParent === null && tr.style.display === 'none') return; // any hidden row
            var cells = Array.prototype.slice.call(tr.children);
            if (cells.length < heads.length) return;               // colspan / spacer row
            var card = document.createElement('div'); card.className = 'pp-card';
            var head = document.createElement('div'); head.className = 'pp-card-head';
            var body = document.createElement('div'); body.className = 'pp-card-body';
            var foot = document.createElement('div'); foot.className = 'pp-card-foot';
            var titled = false;
            cols.forEach(function (c) {
                var cell = cells[c.i]; if (!cell) return;
                if (c.kind === 'actions') { foot.innerHTML = cell.innerHTML; return; }
                if (c.kind === 'select' || c.kind === 'details') return;
                if (!titled && cell.textContent.trim() !== '') {
                    var t = document.createElement('div'); t.className = 'pp-card-title';
                    t.innerHTML = cell.innerHTML; head.appendChild(t); titled = true; return;
                }
                var row = document.createElement('div'); row.className = 'pp-card-row';
                var dt = document.createElement('dt'); dt.textContent = c.label;
                var dd = document.createElement('dd'); dd.innerHTML = cell.innerHTML;
                row.appendChild(dt); row.appendChild(dd); body.appendChild(row);
            });
            if (!titled) { var d = document.createElement('div'); d.className = 'pp-card-title'; d.textContent = 'Record'; head.appendChild(d); }
            card.appendChild(head); card.appendChild(body);
            if (foot.innerHTML.trim() !== '') card.appendChild(foot);
            frag.appendChild(card);
        });
        mount.innerHTML = ''; mount.appendChild(frag); mount.setAttribute('data-built', '1');
    }
    function apply(view) {
        var table = document.querySelector('.ppd-grid-table');
        var wrap = table ? (table.closest('.ppd-grid-table-wrap') || table) : null;
        var mount = document.getElementById('ppCards');
        if (!wrap || !mount) return;
        if (view === 'cards') { buildCards(table, mount); wrap.hidden = true; mount.hidden = false; }
        else { mount.hidden = true; wrap.hidden = false; }
        var toggle = document.querySelector('.ppd-view-toggle');
        if (toggle) Array.prototype.forEach.call(toggle.querySelectorAll('[data-pp-view]'), function (b) {
            b.classList.toggle('is-active', b.getAttribute('data-pp-view') === view);
        });
    }
    function storeKey() { return 'ppView:' + (document.body.getAttribute('data-pp-table') || location.pathname); }
    window.ppInitCardsView = function () {
        var toggle = document.querySelector('.ppd-view-toggle');
        var mount = document.getElementById('ppCards');
        if (!toggle || !mount) return;
        toggle.addEventListener('click', function (e) {
            var b = e.target.closest('[data-pp-view]'); if (!b) return;
            var v = b.getAttribute('data-pp-view');
            try { localStorage.setItem(storeKey(), v); } catch (x) {}
            if (window.ppSyncViewUrl) window.ppSyncViewUrl();
            apply(v);
        });
        var saved = null; try { saved = localStorage.getItem(storeKey()); } catch (x) {}
        if (saved === 'cards') apply('cards');
    };
    if (document.readyState !== 'loading') window.ppInitCardsView();
    else document.addEventListener('DOMContentLoaded', window.ppInitCardsView);
})();

/* ═══ pp-dl: deadline tracker — header chips, clean body, hover record preview ═══
   1. When the tracker sits inside a foldable card, its title duplicates the card
      header — so we lift the risk chips up into that header (right-aligned) and
      drop the redundant in-body title. Standalone use keeps its own header.
   2. Hovering / focusing a row shows a lightweight, RLS-scoped snapshot of the
      linked record fetched once from api/record-preview.php (cached). */
(function () {
    function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }

    /* ── 1. Lift chips into the enclosing card header, hide the in-body title ── */
    function reflow(host) {
        if (host._ppDlReflow) return; host._ppDlReflow = 1;
        var sec = host.closest ? host.closest('.ppd-c-section') : null;
        var head = sec ? sec.querySelector(':scope > .h') : null;
        var chips = host.querySelector('.pp-dl-chips');
        var body = host.querySelector('.pp-dl-head');
        if (!head || !chips || !chips.children.length) return;   // standalone → leave as designed
        chips.classList.add('pp-dl-chips-inhead');
        var fold = head.querySelector('.ppd-fold-ic');
        if (fold) head.insertBefore(chips, fold); else head.appendChild(chips);
        if (body) body.style.display = 'none';                   // title now lives in the card header
    }

    /* ── 2. Hover / focus preview card ── */
    var tip = null, cache = {}, hideT = null, curKey = '';
    function tipEl() {
        if (tip) return tip;
        tip = document.createElement('div');
        tip.className = 'pp-dl-preview';
        tip.setAttribute('role', 'tooltip');
        tip.addEventListener('mouseenter', function () { clearTimeout(hideT); });
        tip.addEventListener('mouseleave', hide);
        document.body.appendChild(tip);
        return tip;
    }
    function hide() { clearTimeout(hideT); hideT = setTimeout(function () { if (tip) { tip.classList.remove('is-on'); curKey = ''; } }, 120); }
    function place(item) {
        var t = tipEl(), r = item.getBoundingClientRect();
        t.style.visibility = 'hidden'; t.classList.add('is-on');
        var tw = t.offsetWidth, th = t.offsetHeight, gap = 10, vw = window.innerWidth, vh = window.innerHeight;
        var left = r.right + gap; if (left + tw > vw - 8) left = r.left - gap - tw;   // flip to the left if no room
        if (left < 8) left = Math.max(8, (vw - tw) / 2);
        var top = r.top + r.height / 2 - th / 2;
        top = Math.max(8, Math.min(top, vh - th - 8));
        t.style.left = Math.round(left + window.pageXOffset) + 'px';
        t.style.top = Math.round(top + window.pageYOffset) + 'px';
        t.style.visibility = '';
    }
    function loading(item) { var t = tipEl(); t.innerHTML = '<div class="pp-dl-pv-load"><i class="bi bi-hourglass-split"></i></div>'; place(item); }
    function render(item, j, openHref, openLabel) {
        var t = tipEl(), h = '';
        h += '<div class="pp-dl-pv-h"><span class="pp-dl-pv-tag">' + esc(j.table || '') + '</span><strong>' + esc(j.title || '') + '</strong></div>';
        if (j.fields && j.fields.length) {
            h += '<dl class="pp-dl-pv-f">';
            j.fields.forEach(function (f) { h += '<dt>' + esc(f.label) + '</dt><dd>' + esc(f.value) + '</dd>'; });
            h += '</dl>';
        }
        if (openHref) h += '<a class="pp-dl-pv-open" href="' + esc(openHref) + '">' + esc(openLabel || 'Open') + ' <i class="bi bi-arrow-right-short"></i></a>';
        t.innerHTML = h; place(item);
    }
    function show(item) {
        var host = item.closest('.pp-dl[data-dl-preview]'); if (!host) return;
        var table = host.getAttribute('data-dl-table') || '', id = item.getAttribute('data-k') || '';
        if (!table || id === '') return;
        var link = item.querySelector('.pp-dl-open');
        var openHref = link ? link.getAttribute('href') : '', openLabel = link ? (link.textContent || '').trim() : '';
        var key = table + '' + id; curKey = key; clearTimeout(hideT);
        if (cache[key]) { if (curKey === key) render(item, cache[key], openHref, openLabel); return; }
        loading(item);
        fetch((window.PP_BASE || '') + '/api/record-preview.php?table=' + encodeURIComponent(table) + '&id=' + encodeURIComponent(id), { credentials: 'same-origin' })
            .then(function (r) { return r.json(); })
            .then(function (j) { if (!j || !j.ok) { if (tip) tip.classList.remove('is-on'); return; } cache[key] = j; if (curKey === key) render(item, j, openHref, openLabel); })
            .catch(function () { if (tip) tip.classList.remove('is-on'); });
    }

    document.addEventListener('mouseover', function (e) { var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null; if (it) show(it); });
    document.addEventListener('mouseout', function (e) { var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null; if (it && !it.contains(e.relatedTarget) && (!tip || !tip.contains(e.relatedTarget))) hide(); });
    document.addEventListener('focusin', function (e) { var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null; if (it) show(it); });
    document.addEventListener('focusout', function (e) { var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null; if (it) hide(); });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && tip) { tip.classList.remove('is-on'); curKey = ''; } });
    window.addEventListener('scroll', function () { if (tip && tip.classList.contains('is-on')) { tip.classList.remove('is-on'); curKey = ''; } }, true);

    /* ── whole row is a click target (except the explicit links inside it) ── */
    document.addEventListener('click', function (e) {
        var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null;
        if (!it || e.target.closest('a')) return;
        var link = it.querySelector('.pp-dl-open'); var href = link && link.getAttribute('href');
        if (href) window.location.href = href;
    });
    document.addEventListener('keydown', function (e) {
        if (e.key !== 'Enter') return;
        var it = e.target.closest ? e.target.closest('.pp-dl-item.is-preview') : null;
        if (!it || e.target.tagName === 'A') return;
        var link = it.querySelector('.pp-dl-open'); var href = link && link.getAttribute('href');
        if (href) window.location.href = href;
    });

    document.addEventListener('DOMContentLoaded', function () {
        var ls = document.querySelectorAll('.pp-dl'); for (var i = 0; i < ls.length; i++) reflow(ls[i]);
    });
})();
JS;
        $path = $jsDir . '/app.js';
        file_put_contents($path, $content);
        $this->log('Wrote assets/js/app.js');

        $written = [$path];
        $written[] = $this->generatePdfApiJs($jsDir);
        $written[] = $this->generateQrJs($jsDir);
        return $written;
    }

    /**
     * pp-pdf.js — a small, reusable client-side PDF API (window.PPpdf) available
     * app-wide (included by the shell). It lazy-loads the bundled html2pdf.js on
     * first use so pages that never make a PDF pay nothing. Any custom code, click
     * action or event can call it:
     *   PPpdf.fromElement(el, {orientation, filename})   → PDF from a DOM element
     *   PPpdf.fromRecord('orders', 42)                   → PDF from a record's pdf.php
     *   PPpdf.open('orders', 42)                          → open the PDF page
     */
    private function generatePdfApiJs(string $jsDir): string
    {
        $content = <<<'JS'
/**
 * PPphp PDF API — window.PPpdf (client-side, lazy-loads html2pdf.js).
 */
(function () {
    'use strict';
    function base() { return window.PP_BASE || ''; }
    var libPromise = null;
    function ensureLib() {
        if (window.html2pdf) return Promise.resolve();
        if (libPromise) return libPromise;
        libPromise = new Promise(function (resolve, reject) {
            var s = document.createElement('script');
            s.src = base() + '/assets/vendor/html2pdf/html2pdf.bundle.min.js';
            s.onload = function () { resolve(); };
            s.onerror = function () { libPromise = null; reject(new Error('PPpdf: html2pdf failed to load')); };
            document.head.appendChild(s);
        });
        return libPromise;
    }
    function buildOpts(o) {
        o = o || {};
        var name = o.filename || 'document';
        if (!/\.pdf$/i.test(name)) name += '.pdf';
        return {
            margin: o.margin || [10, 10, 12, 10],
            filename: name,
            image: { type: 'jpeg', quality: o.quality || 0.98 },
            html2canvas: { scale: o.scale || 2, useCORS: true, backgroundColor: o.background || '#ffffff', windowWidth: (o.el && o.el.scrollWidth) || undefined },
            jsPDF: { unit: 'mm', format: o.format || 'a4', orientation: o.orientation === 'landscape' ? 'landscape' : 'portrait' },
            pagebreak: { mode: ['css', 'legacy'] }
        };
    }
    var PPpdf = {
        /** Generate + save (or output) a PDF from a DOM element or selector. Returns a Promise. */
        fromElement: function (el, o) {
            if (typeof el === 'string') el = document.querySelector(el);
            if (!el) return Promise.reject(new Error('PPpdf: element not found'));
            o = o || {}; o.el = el;
            return ensureLib().then(function () {
                var work = window.html2pdf().set(buildOpts(o)).from(el);
                return o.output ? work.output(o.output) : work.save();
            });
        },
        /**
         * Generate a record's PDF from its generated pdf.php, from anywhere in the
         * app. Renders inside a hidden iframe (so the document's own styles apply)
         * and honours the table's configured orientation/filename.
         */
        fromRecord: function (table, id, o) {
            o = o || {};
            var url = base() + '/generated/' + encodeURIComponent(table) + '/pdf.php?id='
                + encodeURIComponent(id) + '&embed=1&download=1'
                + (o.orientation ? '&orientation=' + encodeURIComponent(o.orientation) : '');
            return new Promise(function (resolve) {
                var f = document.createElement('iframe');
                f.style.cssText = 'position:fixed;left:-9999px;top:0;width:900px;height:10px;border:0;';
                f.src = url;
                document.body.appendChild(f);
                // The embedded page auto-generates + downloads on load; clean up after.
                setTimeout(function () { try { f.remove(); } catch (e) {} resolve(); }, 5000);
            });
        },
        /** Open a record's PDF page in a new tab. */
        open: function (table, id) {
            window.open(base() + '/generated/' + encodeURIComponent(table) + '/pdf.php?id=' + encodeURIComponent(id), '_blank');
        },
        /**
         * Server-rendered PDF (headless, via dompdf + the api/pdf.php endpoint).
         * Downloads a record's PDF built entirely on the server — no browser
         * rendering. opts: { orientation, margin (e.g. '15mm'), inline }.
         */
        server: function (table, id, opts) {
            opts = opts || {};
            var q = 'table=' + encodeURIComponent(table) + '&id=' + encodeURIComponent(id);
            if (opts.orientation) q += '&orientation=' + encodeURIComponent(opts.orientation);
            if (opts.margin) q += '&margin=' + encodeURIComponent(opts.margin);
            if (opts.inline) q += '&inline=1';
            var url = base() + '/api/pdf.php?' + q;
            if (opts.inline) { window.open(url, '_blank'); } else { window.location.href = url; }
        },
        ensureLib: ensureLib
    };
    window.PPpdf = PPpdf;
})();
JS;
        $path = $jsDir . '/pp-pdf.js';
        file_put_contents($path, $content);
        $this->log('Wrote assets/js/pp-pdf.js');
        return $path;
    }

    /**
     * pp-qr.js — a tiny, dependency-free QR generator (window.PPQR). Used by the
     * 2FA setup page to render the otpauth:// URI locally, so the TOTP secret is
     * never sent to any third-party QR service. Byte mode, versions 1-10, ECC-M.
     */
    private function generateQrJs(string $jsDir): string
    {
        $content = <<<'QRSOURCE'
/*!
 * pp-qr.js — tiny self-contained QR Code generator (byte mode, ECC level M).
 * No dependencies, no network. Renders text as a QR matrix and paints it into a
 * target element. Used by the generated app's 2FA setup so a TOTP secret is
 * encoded entirely in the user's own browser. Geometry follows the reference
 * algorithm by Project Nayuki (public domain). Versions 1–10 cover otpauth:// URIs.
 */
(function (global) {
    'use strict';

    // ── GF(256) for Reed–Solomon ───────────────────────────────────────────
    var EXP = new Array(512), LOG = new Array(256);
    (function () {
        var x = 1;
        for (var i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
        for (var j = 255; j < 512; j++) EXP[j] = EXP[j - 255];
    })();
    function gmul(a, b) { return (a === 0 || b === 0) ? 0 : EXP[LOG[a] + LOG[b]]; }
    function rsDivisor(degree) {
        var result = new Array(degree).fill(0);
        result[degree - 1] = 1;
        var root = 1;
        for (var i = 0; i < degree; i++) {
            for (var j = 0; j < result.length; j++) {
                result[j] = gmul(result[j], root);
                if (j + 1 < result.length) result[j] ^= result[j + 1];
            }
            root = gmul(root, 2);
        }
        return result;
    }
    function rsEncode(data, ecLen) {
        var divisor = rsDivisor(ecLen);
        var result = new Array(ecLen).fill(0);
        for (var d = 0; d < data.length; d++) {
            var factor = data[d] ^ result.shift();
            result.push(0);
            for (var i = 0; i < result.length; i++) result[i] ^= gmul(divisor[i], factor);
        }
        return result;
    }

    // ── Capacity + block structure per version, ECC level M ────────────────
    // [ dataCodewords, ecCodewordsPerBlock, numBlocksG1, dataCwG1, numBlocksG2, dataCwG2 ]
    var VERS = {
        1: [16, 10, 1, 16, 0, 0], 2: [28, 16, 1, 28, 0, 0], 3: [44, 26, 1, 44, 0, 0],
        4: [64, 18, 2, 32, 0, 0], 5: [86, 24, 2, 43, 0, 0], 6: [108, 16, 4, 27, 0, 0],
        7: [124, 18, 4, 31, 0, 0], 8: [154, 22, 2, 38, 2, 39], 9: [182, 22, 3, 36, 2, 37],
        10: [216, 26, 4, 43, 1, 44]
    };
    var ALIGN = {
        1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34],
        7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50]
    };

    function utf8len(text) { var n = 0; for (var i = 0; i < text.length; i++) { var c = text.charCodeAt(i); n += c < 0x80 ? 1 : c < 0x800 ? 2 : 3; } return n; }
    function chooseVersion(byteLen) {
        for (var v = 1; v <= 10; v++) {
            var cci = v < 10 ? 8 : 16;
            if (4 + cci + byteLen * 8 <= VERS[v][0] * 8) return v;
        }
        throw new Error('QR: data too long');
    }

    // ── Data codeword stream (data + interleaved EC) ───────────────────────
    function buildCodewords(text, version) {
        var bytes = [];
        for (var i = 0; i < text.length; i++) {
            var c = text.charCodeAt(i);
            if (c < 0x80) bytes.push(c);
            else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
            else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
        }
        var cci = version < 10 ? 8 : 16, bs = [];
        function put(val, len) { for (var b = len - 1; b >= 0; b--) bs.push((val >> b) & 1); }
        put(0b0100, 4);
        put(bytes.length, cci);
        for (var k = 0; k < bytes.length; k++) put(bytes[k], 8);
        var totalData = VERS[version][0], capBits = totalData * 8;
        for (var t = 0; t < 4 && bs.length < capBits; t++) bs.push(0);
        while (bs.length % 8 !== 0) bs.push(0);
        var dcw = [];
        for (var p = 0; p < bs.length; p += 8) { var v = 0; for (var q = 0; q < 8; q++) v = (v << 1) | bs[p + q]; dcw.push(v); }
        var pad = [0xEC, 0x11], pi = 0;
        while (dcw.length < totalData) dcw.push(pad[pi++ % 2]);

        var ecLen = VERS[version][1];
        var nb1 = VERS[version][2], d1 = VERS[version][3], nb2 = VERS[version][4], d2 = VERS[version][5];
        var blocks = [], off = 0;
        for (var b1 = 0; b1 < nb1; b1++) { blocks.push(dcw.slice(off, off + d1)); off += d1; }
        for (var b2 = 0; b2 < nb2; b2++) { blocks.push(dcw.slice(off, off + d2)); off += d2; }
        var ecBlocks = blocks.map(function (blk) { return rsEncode(blk, ecLen); });

        var result = [], maxD = Math.max(d1, d2 || 0);
        for (var col = 0; col < maxD; col++) for (var bi = 0; bi < blocks.length; bi++) if (col < blocks[bi].length) result.push(blocks[bi][col]);
        for (var ec = 0; ec < ecLen; ec++) for (var bj = 0; bj < ecBlocks.length; bj++) result.push(ecBlocks[bj][ec]);
        return result;
    }

    // ── BCH bits for format & version info ─────────────────────────────────
    function getBit(x, i) { return (x >>> i) & 1; }
    function formatValue(mask) {              // ECC level M formatBits = 0
        var data = (0 << 3) | mask, rem = data;
        for (var i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
        return ((data << 10) | rem) ^ 0x5412;
    }
    function versionValue(version) {
        var rem = version;
        for (var i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
        return (version << 12) | rem;
    }

    // ── Build QR ───────────────────────────────────────────────────────────
    function generate(text) {
        var version = chooseVersion(utf8len(text));
        var cw = buildCodewords(text, version);
        var size = version * 4 + 17;
        var mod = [], fn = [], i, j, r, c;
        for (i = 0; i < size; i++) { mod.push(new Array(size).fill(0)); fn.push(new Array(size).fill(false)); }
        function setF(rr, cc, dark) { if (rr < 0 || cc < 0 || rr >= size || cc >= size) return; mod[rr][cc] = dark ? 1 : 0; fn[rr][cc] = true; }

        // Timing (whole row/col 6), then finders overwrite the overlap.
        for (i = 0; i < size; i++) { setF(6, i, i % 2 === 0); setF(i, 6, i % 2 === 0); }
        function finder(cr, cc) {
            for (var dr = -4; dr <= 4; dr++) for (var dc = -4; dc <= 4; dc++) {
                var d = Math.max(Math.abs(dr), Math.abs(dc));
                setF(cr + dr, cc + dc, d !== 2 && d !== 4);
            }
        }
        finder(3, 3); finder(3, size - 4); finder(size - 4, 3);

        // Alignment patterns (all combos except the three finder corners).
        var ap = ALIGN[version], na = ap.length;
        for (var a = 0; a < na; a++) for (var b = 0; b < na; b++) {
            if ((a === 0 && b === 0) || (a === 0 && b === na - 1) || (a === na - 1 && b === 0)) continue;
            var cr = ap[a], cc = ap[b];
            for (var dr = -2; dr <= 2; dr++) for (var dc = -2; dc <= 2; dc++) setF(cr + dr, cc + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
        }

        // Reserve format (both copies) + dark module + version info as function.
        var fmt1 = [[0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], [7, 8], [8, 8], [8, 7], [8, 5], [8, 4], [8, 3], [8, 2], [8, 1], [8, 0]];
        var fmt2 = [[8, size - 1], [8, size - 2], [8, size - 3], [8, size - 4], [8, size - 5], [8, size - 6], [8, size - 7], [8, size - 8], [size - 7, 8], [size - 6, 8], [size - 5, 8], [size - 4, 8], [size - 3, 8], [size - 2, 8], [size - 1, 8]];
        for (i = 0; i < 15; i++) { fn[fmt1[i][0]][fmt1[i][1]] = true; fn[fmt2[i][0]][fmt2[i][1]] = true; }
        setF(size - 8, 8, true); // dark module
        if (version >= 7) {
            for (i = 0; i < 18; i++) { var rr = Math.floor(i / 3), cc2 = i % 3; fn[rr][size - 11 + cc2] = true; fn[size - 11 + cc2][rr] = true; }
        }

        // Draw data (zigzag), skipping function modules.
        var bits = [];
        for (i = 0; i < cw.length; i++) for (j = 7; j >= 0; j--) bits.push((cw[i] >> j) & 1);
        var bi = 0;
        for (var right = size - 1; right >= 1; right -= 2) {
            if (right === 6) right = 5;
            for (var vert = 0; vert < size; vert++) {
                for (j = 0; j < 2; j++) {
                    var col = right - j;
                    var upward = ((right + 1) & 2) === 0;
                    var row = upward ? size - 1 - vert : vert;
                    if (!fn[row][col]) { mod[row][col] = (bi < bits.length ? bits[bi] : 0); bi++; }
                }
            }
        }

        // Draw version info (mask-independent) once.
        if (version >= 7) {
            var vv = versionValue(version);
            for (i = 0; i < 18; i++) { var bit = getBit(vv, i), b1 = Math.floor(i / 3), b2 = size - 11 + i % 3; mod[b1][b2] = bit; mod[b2][b1] = bit; }
        }

        function maskFn(k, r2, c2) {
            switch (k) {
                case 0: return (r2 + c2) % 2 === 0;
                case 1: return r2 % 2 === 0;
                case 2: return c2 % 3 === 0;
                case 3: return (r2 + c2) % 3 === 0;
                case 4: return (Math.floor(r2 / 2) + Math.floor(c2 / 3)) % 2 === 0;
                case 5: return (r2 * c2) % 2 + (r2 * c2) % 3 === 0;
                case 6: return ((r2 * c2) % 2 + (r2 * c2) % 3) % 2 === 0;
                case 7: return ((r2 + c2) % 2 + (r2 * c2) % 3) % 2 === 0;
            }
        }
        function drawFormat(mask) {
            var val = formatValue(mask);
            for (var x = 0; x < 15; x++) { var bit = getBit(val, x); mod[fmt1[x][0]][fmt1[x][1]] = bit; mod[fmt2[x][0]][fmt2[x][1]] = bit; }
        }
        function penalty() {
            var n = size, p = 0, x, y, run, cnt;
            for (y = 0; y < n; y++) { run = 1; for (x = 1; x < n; x++) { if (mod[y][x] === mod[y][x - 1]) { run++; if (run === 5) p += 3; else if (run > 5) p++; } else run = 1; } }
            for (x = 0; x < n; x++) { run = 1; for (y = 1; y < n; y++) { if (mod[y][x] === mod[y - 1][x]) { run++; if (run === 5) p += 3; else if (run > 5) p++; } else run = 1; } }
            for (y = 0; y < n - 1; y++) for (x = 0; x < n - 1; x++) { var s = mod[y][x] + mod[y][x + 1] + mod[y + 1][x] + mod[y + 1][x + 1]; if (s === 0 || s === 4) p += 3; }
            cnt = 0; for (y = 0; y < n; y++) for (x = 0; x < n; x++) cnt += mod[y][x];
            p += Math.floor(Math.abs(cnt * 100 / (n * n) - 50) / 5) * 10;
            return p;
        }

        // Try all 8 masks, keep the lowest-penalty one. Apply mask to data cells,
        // draw its format bits, score, then revert data cells for the next trial.
        var bestMask = 0, bestPen = Infinity;
        for (var k = 0; k < 8; k++) {
            for (r = 0; r < size; r++) for (c = 0; c < size; c++) if (!fn[r][c] && maskFn(k, r, c)) mod[r][c] ^= 1;
            drawFormat(k);
            var pen = penalty();
            if (pen < bestPen) { bestPen = pen; bestMask = k; }
            for (r = 0; r < size; r++) for (c = 0; c < size; c++) if (!fn[r][c] && maskFn(k, r, c)) mod[r][c] ^= 1;
        }
        for (r = 0; r < size; r++) for (c = 0; c < size; c++) if (!fn[r][c] && maskFn(bestMask, r, c)) mod[r][c] ^= 1;
        drawFormat(bestMask);
        return mod;
    }

    // ── Public render ──────────────────────────────────────────────────────
    function render(el, text, opts) {
        opts = opts || {};
        var m = generate(text), n = m.length;
        var quiet = opts.quiet == null ? 4 : opts.quiet, scale = opts.scale || 5;
        var total = (n + quiet * 2) * scale;
        var dark = opts.dark || '#000000', light = opts.light || '#ffffff';
        var canvas = document.createElement('canvas');
        canvas.width = canvas.height = total;
        canvas.style.width = canvas.style.height = total + 'px';
        canvas.style.imageRendering = 'pixelated';
        var ctx = canvas.getContext('2d');
        ctx.fillStyle = light; ctx.fillRect(0, 0, total, total);
        ctx.fillStyle = dark;
        for (var r = 0; r < n; r++) for (var c = 0; c < n; c++) if (m[r][c]) ctx.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale);
        el.innerHTML = ''; el.appendChild(canvas);
        return canvas;
    }

    var api = { generate: generate, render: render };
    if (typeof module !== 'undefined' && module.exports) module.exports = api;
    global.PPQR = api;
})(typeof window !== 'undefined' ? window : this);
QRSOURCE;
        $path = $jsDir . '/pp-qr.js';
        file_put_contents($path, $content);
        $this->log('Wrote assets/js/pp-qr.js');
        return $path;
    }


    /**
     * Write database.sql: everything needed to stand up the app's database
     * on a remote MySQL server — CREATE DATABASE, auth tables, the real
     * schema of every selected table (SHOW CREATE TABLE), and seed data.
     * Falls back to an inferred schema when the project DB is unreachable.
     */
    /**
     * SQL for the compatibility `users` view over an existing auth table
     * (existing-table mode). Each auth field maps to its physical column under
     * the logical name the runtime expects; the console's ensureUsersCompatView
     * has already added any missing columns, so the dumped table schema carries
     * them and the resulting view is insertable (register / reset write through
     * it). Emitted after the developer's table is created.
     */
    private function authUsersViewSql(array $sec): string
    {
        $ident = static fn(string $v, string $d): string => (preg_replace('/[^A-Za-z0-9_]/', '', $v) ?: $d);
        $real = $ident((string) ($sec['users_table'] ?? ''), 'users');
        $map = [
            'id'          => $sec['id_field']          ?? 'id',
            'username'    => $sec['username_field']    ?? 'username',
            'email'       => $sec['email_field']       ?? 'email',
            'password'    => $sec['password_field']    ?? 'password',
            'fullname'    => $sec['fullname_field']    ?? 'fullname',
            'role'        => $sec['role_field']        ?? 'role',
            'status'      => $sec['status_field']      ?? 'status',
            'totp_secret' => $sec['totp_secret_field'] ?? 'totp_secret',
        ];
        $cols = [];
        foreach ($map as $logical => $phys) {
            $cols[] = '`' . $ident((string) $phys, $logical) . "` AS `{$logical}`";
        }
        return "-- Auth compatibility view: the app addresses `users`; accounts live in `{$real}`.\n"
            . "DROP TABLE IF EXISTS `users`;\n"
            . 'CREATE OR REPLACE VIEW `users` AS SELECT ' . implode(', ', $cols) . " FROM `{$real}`;\n\n";
    }

    public function generateDatabaseSql(string $outputDir, array $tables, string $slug, array $dbConfig): array
    {
        $this->log('Generating database SQL (schema.sql + seed.sql + database.sql)…');
        // Re-assert the PROJECT database connection: earlier build phases (theme
        // compile, events, etc.) may have switched the active DB, and the data dump
        // + view enumeration below MUST run against the project's own database.
        // Without this the SELECTs hit the wrong connection and seed data is empty.
        try {
            \PpPhp\Core\DB::Init($slug);
            \PpPhp\Core\DB::SetConnection($slug);
        } catch (\Throwable $e) {
            $this->log('database SQL: could not re-open project DB (' . $e->getMessage() . ') — data/views may be incomplete');
        }
        $dbName = preg_replace('/[^a-zA-Z0-9_$]/', '', (string) ($dbConfig['name'] ?? $slug)) ?: $slug;
        // Data (INSERTs) accumulates here so it can ship as a separate seed.sql; the
        // schema (CREATE TABLE/VIEW) stays in $sql. database.sql is the two combined.
        $seedBody = '';

        // $sql holds the PURE schema BODY (tables + views) — no CREATE DATABASE/USE
        // header, so schema.sql imports into whatever database the operator already
        // selected (phpMyAdmin) or named (`mysql -u U -p YOUR_DB < schema.sql`). The
        // hardcoded `USE <dev-db>` used to hijack imports into the wrong database on
        // a differently-named live DB — the #1 manual-deploy footgun. Only the
        // combined database.sql self-creates the DB (for `mysql < database.sql`).
        $sql = '';

        // Auth mode: "Use existing table" with a name other than `users` means
        // the app authenticates through a `users` VIEW over the developer's own
        // table (appended after the application tables below) — so this fresh
        // install must NOT create a competing base `users` table or seed into it.
        $__sec = (array) ($this->ctx['security'] ?? []);
        $existingAuth = !empty($__sec['db_auth_enabled']) && empty($__sec['create_new_table'])
            && strtolower(trim((string) ($__sec['users_table'] ?? 'users'))) !== 'users';

        // Users table (standard / create-new mode only)
        if (!$existingAuth) {
            $sql .= <<<'SQL'
-- Users / authentication table
CREATE TABLE IF NOT EXISTS `users` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `username` VARCHAR(100) NOT NULL UNIQUE,
    `email` VARCHAR(255) NOT NULL UNIQUE,
    `password` VARCHAR(255) NOT NULL,
    `fullname` VARCHAR(255) DEFAULT NULL,
    `role` VARCHAR(50) NOT NULL DEFAULT 'admin',
    -- `pending` is part of the set on purpose: account activation (email
    -- confirmation / administrator approval) parks new accounts here, and an
    -- ENUM without it makes that INSERT fail outright under strict mode.
    `status` ENUM('active','pending','inactive','banned') NOT NULL DEFAULT 'active',
    `avatar` VARCHAR(500) DEFAULT NULL,
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX `idx_users_username` (`username`),
    INDEX `idx_users_email` (`email`),
    INDEX `idx_users_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

SQL;
        }

        // Auth satellite tables — keyed by user id with no FK, so they work
        // whether `users` is a base table or a compatibility view.
        $sql .= <<<'SQL'
-- Account activation tokens (email-confirmation sign-up)
CREATE TABLE IF NOT EXISTS `account_activations` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `user_id` INT UNSIGNED NOT NULL,
    `token` VARCHAR(64) NOT NULL,
    `expires_at` DATETIME NOT NULL,
    `created_at` DATETIME NOT NULL,
    INDEX `idx_act_token` (`token`),
    INDEX `idx_act_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Password reset tokens
CREATE TABLE IF NOT EXISTS `password_resets` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `user_id` INT UNSIGNED NOT NULL,
    `token` VARCHAR(255) NOT NULL,
    `expires_at` DATETIME NOT NULL,
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX `idx_resets_token` (`token`),
    INDEX `idx_resets_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Remember me tokens
CREATE TABLE IF NOT EXISTS `remember_tokens` (
    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `user_id` INT UNSIGNED NOT NULL,
    `token` VARCHAR(255) NOT NULL,
    `expires_at` DATETIME NOT NULL,
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX `idx_remember_token` (`token`),
    INDEX `idx_remember_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Two-factor authentication. One row per user; each user runs exactly ONE
-- method (authenticator / email / SMS) at a time — `method` says which,
-- `secret` is the TOTP seed (authenticator only), `backup_codes` the hashed
-- single-use recovery codes. Matches core/Auth/TwoFactor::ensureTable().
CREATE TABLE IF NOT EXISTS `user_2fa` (
    `user_id` INT NOT NULL PRIMARY KEY,
    `method` VARCHAR(10) DEFAULT NULL,
    `secret` VARCHAR(255) DEFAULT NULL,
    `enabled` TINYINT(1) NOT NULL DEFAULT 0,
    `backup_codes` TEXT DEFAULT NULL,
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

SQL;
        // Administrator seed: carry over any admin the user created (Security →
        // Add admin user), otherwise seed a default admin/admin so the freshly
        // built app can be signed into immediately.
        // In existing-table mode the accounts already live in the developer's
        // table (dumped with the application tables below), so don't seed `users`.
        // In installer mode we ship no admin at all — install.php creates the first
        // administrator from operator-supplied credentials, so no dev hash leaks.
        if (!$existingAuth && !$this->standaloneInstaller) {
            $seedBody .= $this->usersSeed();
        }
        // Notifications table (Misc → Advanced, or the Calendar's In-app notifications)
        if ($this->notifBellNeeded()) {
            $notifTable = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($this->ctx['advanced']['notifTableName'] ?? 'notifications')) ?: 'notifications';
            $sql .= "-- In-app notifications (enabled in Misc → Advanced)\n"
                . "CREATE TABLE IF NOT EXISTS `$notifTable` (\n"
                . "    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,\n"
                . "    `user_id` INT UNSIGNED DEFAULT NULL,\n"
                . "    `message` VARCHAR(500) NOT NULL,\n"
                . "    `link` VARCHAR(500) DEFAULT NULL,\n"
                . "    `is_read` TINYINT(1) NOT NULL DEFAULT 0,\n"
                . "    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,\n"
                . "    INDEX `idx_notif_user` (`user_id`, `is_read`)\n"
                . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n\n";
        }

        $sql .= "-- ── Application Tables (structure) ──\n\n";
        $seedBody .= "-- ── Application Data ──\n\n";

        foreach ($tables as $tableName => $fields) {
            $safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $tableName);
            if ($safe === '') continue;

            $dumped = false;
            try {
                $row = \PpPhp\Core\DB::Query("SHOW CREATE TABLE `{$safe}`")->fetchAssoc();
                $create = $row['Create Table'] ?? null;
                if ($create !== null) {
                    $create = preg_replace('/^CREATE TABLE /', 'CREATE TABLE IF NOT EXISTS ', $create);
                    $sql .= "-- Table: $safe (live schema)\n$create;\n\n";
                    $seedBody .= $this->dumpTableData($safe);
                    $dumped = true;
                }
            } catch (\Throwable $e) {
                $this->log("Live dump unavailable for $safe ({$e->getMessage()}) — using inferred schema");
            }

            if (!$dumped) {
                $sql .= $this->inferredCreateTable($safe, $fields);
            }
        }

        // Project database VIEWS (e.g. reporting views a dashboard binds to). These
        // are NOT in $tables, so without this a dashboard shows "No data" on a fresh
        // server. Emitted AFTER the tables and made portable (DEFINER stripped).
        $sql .= $this->projectViewsSql($existingAuth ? ['users'] : []);

        // Compatibility `users` VIEW over the developer's existing auth table,
        // emitted AFTER that table has been created above. Exposes the mapped
        // columns under the logical names the auth runtime expects.
        if ($existingAuth) {
            $sql .= "\n" . $this->authUsersViewSql($__sec);
        }

        // Compose the three artifacts from the pure schema body ($sql) + data body
        // ($seedBody). schema.sql + seed.sql are PORTABLE (no CREATE DATABASE/USE):
        // they import into the operator's selected/named database. Only the combined
        // database.sql self-creates + selects the dev DB name, for the one-shot
        // `mysql -u USER -p < database.sql` power path.
        $portableNote = "-- Import into the database you already created:\n"
            . "--   phpMyAdmin → select your DB → Import → choose this file\n"
            . "--   CLI        → mysql -u USER -p YOUR_DB < " . '%FILE%' . "\n";
        $fkOpen  = "SET FOREIGN_KEY_CHECKS = 0;\n\n";
        $fkClose = "SET FOREIGN_KEY_CHECKS = 1;\n";

        $schemaSql = "-- ============================================================\n"
            . "-- Schema (tables + views) for: $slug\n-- Generated: " . date('c') . " by PPphp\n"
            . str_replace('%FILE%', 'schema.sql', $portableNote)
            . "-- ============================================================\n\n"
            . $fkOpen . $sql . $fkClose;

        $seedSql = "-- ============================================================\n"
            . "-- Seed data for: $slug   (run AFTER schema.sql)\n-- Generated: " . date('c') . " by PPphp\n"
            . str_replace('%FILE%', 'seed.sql', $portableNote)
            . "-- ============================================================\n\n"
            . $fkOpen . $seedBody . $fkClose;

        $combinedSql = "-- ============================================================\n"
            . "-- Full database bundle for: $slug  (schema + views + data)\n"
            . "-- Generated: " . date('c') . " by PPphp\n--\n"
            . "-- One-shot import (creates the database for you):\n--   mysql -u USER -p < database.sql\n"
            . "-- Prefer schema.sql + seed.sql when importing into an EXISTING,\n"
            . "-- differently-named database (e.g. shared hosting / DirectAdmin).\n"
            . "-- ============================================================\n\n"
            . "CREATE DATABASE IF NOT EXISTS `$dbName` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\nUSE `$dbName`;\n\n"
            . $fkOpen . $sql . "\n" . $seedBody . $fkClose;

        $written = [];
        foreach (['schema.sql' => $schemaSql, 'seed.sql' => $seedSql, 'database.sql' => $combinedSql] as $fname => $body) {
            $p = $outputDir . '/' . $fname;
            file_put_contents($p, $body);
            $this->filesWritten[] = $p;
            $written[] = $p;
        }
        $this->log('Wrote schema.sql + seed.sql + database.sql (' . round(strlen($combinedSql) / 1024, 1) . ' KB combined)');
        return $written;
    }

    /**
     * Portable CREATE OR REPLACE VIEW statements for every VIEW in the project
     * database, DEFINER stripped + SQL SECURITY INVOKER so they import on any host.
     * MySQL/MariaDB only; other engines (or no perms) degrade to '' gracefully.
     *
     * @param array<int,string> $exclude view names to skip (e.g. the synthetic
     *                                    auth `users` view, emitted separately)
     */
    private function projectViewsSql(array $exclude = []): string
    {
        try {
            $rows = \PpPhp\Core\DB::FetchAll("SHOW FULL TABLES WHERE Table_type = 'VIEW'") ?: [];
        } catch (\Throwable $e) {
            return '';   // non-MySQL dialect or insufficient privilege — skip cleanly
        }
        $out = '';
        foreach ($rows as $row) {
            $vals = array_values((array) $row);
            $name = (string) ($vals[0] ?? '');
            if ($name === '' || in_array($name, $exclude, true)) continue;
            if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) continue;
            try {
                $cr = \PpPhp\Core\DB::Query("SHOW CREATE VIEW `{$name}`")->fetchAssoc();
                $ddl = (string) ($cr['Create View'] ?? '');
                if ($ddl === '') continue;
                $out .= "-- View: {$name} (portable — DEFINER stripped)\n"
                    . \PpPhp\Core\Database\SqlPortability::view($ddl) . ";\n\n";
            } catch (\Throwable $e) {
                $this->log("View dump unavailable for {$name} ({$e->getMessage()})");
            }
        }
        return $out !== '' ? "-- ── Database Views ──\n\n" . $out : '';
    }

    /**
     * Build the administrator seed for the generated `users` table.
     *
     * If the project's configured user table already contains an administrator
     * (e.g. one created via Security → Add admin user), those admin accounts are
     * carried into the build with their existing password hashes — no dummy is
     * added. If none exists, a default `admin` / `admin` account is seeded so the
     * built app is immediately usable (with a note to change it).
     */
    /**
     * The build's administrator-seeding decision, memoized.
     *
     * Reads the project's own users table once for accounts whose role contains
     * "admin" (i.e. created via Security → Database → Add admin user). If any
     * exist, they are carried into the build and NO default is created. If none
     * exist, the build seeds a default `admin` / `admin123` and advertises those
     * credentials on the generated login form (the hint auto-hides once the
     * password is changed — see the login page's runtime check).
     *
     * @return array{seeded:bool, username:string, password:string, admins:list<array<string,string>>}
     */
    private function adminSeed(): array
    {
        if ($this->adminSeedCache !== null) {
            return $this->adminSeedCache;
        }
        $sec   = (array) ($this->ctx['security'] ?? []);
        $ident = static fn(string $v, string $default): string =>
            (preg_replace('/[^a-zA-Z0-9_]/', '', $v) ?: $default);

        $table = $ident((string) ($sec['users_table'] ?? 'users'), 'users');
        $uF    = $ident((string) ($sec['username_field'] ?? 'username'), 'username');
        $eF    = $ident((string) ($sec['email_field'] ?? 'email'), 'email');
        $pF    = $ident((string) ($sec['password_field'] ?? 'password'), 'password');
        $rF    = $ident((string) ($sec['role_field'] ?? 'role'), 'role');
        $nF    = $ident((string) ($sec['fullname_field'] ?? 'fullname'), 'fullname');

        $admins = [];
        try {
            $rows = \PpPhp\Core\DB::FetchAll(
                "SELECT * FROM `{$table}` WHERE `{$rF}` LIKE '%admin%' ORDER BY `{$uF}` LIMIT 50"
            );
            foreach ((array) $rows as $row) {
                $username = trim((string) ($row[$uF] ?? ''));
                $password = (string) ($row[$pF] ?? '');
                if ($username === '' || $password === '') {
                    continue;
                }
                $admins[] = [
                    'username' => $username,
                    'email'    => (string) ($row[$eF] ?? '') ?: ($username . '@example.com'),
                    'password' => $password, // already hashed in the source table
                    'fullname' => (string) ($row[$nF] ?? '') ?: 'Administrator',
                    'role'     => (string) ($row[$rF] ?? '') ?: 'admin',
                ];
            }
        } catch (\Throwable $e) {
            $this->log("Admin seed: could not read admins from `{$table}` ({$e->getMessage()})");
        }

        return $this->adminSeedCache = $admins === []
            ? ['seeded' => true, 'username' => 'admin', 'password' => 'admin123', 'admins' => []]
            : ['seeded' => false, 'username' => '', 'password' => '', 'admins' => $admins];
    }

    private function usersSeed(): string
    {
        $info = $this->adminSeed();

        $out = "-- Administrator account(s)\n";
        if ($info['seeded']) {
            $admins = [[
                'username' => $info['username'],
                'email'    => 'admin@example.com',
                'password' => \PpPhp\Core\Security::HashPassword($info['password']),
                'fullname' => 'Administrator',
                'role'     => 'admin',
            ]];
            $out .= "-- No administrator was configured — a default {$info['username']}/{$info['password']} was seeded.\n"
                 .  "-- IMPORTANT: sign in and change this password immediately.\n";
            $this->log("Admin seed: no admin found — seeded default {$info['username']}/{$info['password']}");
        } else {
            $admins = $info['admins'];
            $this->log('Admin seed: carried ' . count($admins) . ' admin account(s) into the build');
        }

        foreach ($admins as $a) {
            $out .= "INSERT INTO `users` (`username`, `email`, `password`, `fullname`, `role`) VALUES ("
                . $this->sqlLiteral($a['username']) . ', '
                . $this->sqlLiteral($a['email']) . ', '
                . $this->sqlLiteral($a['password']) . ', '
                . $this->sqlLiteral($a['fullname']) . ', '
                . $this->sqlLiteral($a['role']) . ");\n";
        }
        return $out . "\n";
    }

    /** Dump table rows as INSERT statements (capped to keep bundles reasonable). */
    private function dumpTableData(string $table, int $maxRows = 5000): string
    {
        $rows = \PpPhp\Core\DB::FetchAll("SELECT * FROM `{$table}` LIMIT " . ($maxRows + 1));
        if (!is_array($rows) || $rows === []) {
            return '';
        }

        $truncated = count($rows) > $maxRows;
        if ($truncated) {
            $rows = array_slice($rows, 0, $maxRows);
        }

        $columns = array_keys($rows[0]);
        $colList = '`' . implode('`, `', $columns) . '`';

        $out = "-- Seed data for $table (" . count($rows) . " rows" . ($truncated ? ", truncated at $maxRows" : '') . ")\n";
        foreach (array_chunk($rows, 100) as $chunk) {
            $values = [];
            foreach ($chunk as $row) {
                $vals = [];
                foreach ($columns as $col) {
                    $vals[] = $this->sqlLiteral($row[$col] ?? null);
                }
                $values[] = '(' . implode(', ', $vals) . ')';
            }
            $out .= "INSERT INTO `{$table}` ($colList) VALUES\n" . implode(",\n", $values) . ";\n";
        }
        $this->log("Dumped " . count($rows) . " row(s) from $table");
        return $out . "\n";
    }

    private function sqlLiteral(mixed $value): string
    {
        if ($value === null) return 'NULL';
        if (is_int($value) || is_float($value)) return (string) $value;
        $escaped = str_replace(
            ["\\", "'", "\0", "\n", "\r", "\x1a"],
            ["\\\\", "\\'", "\\0", "\\n", "\\r", "\\Z"],
            (string) $value
        );
        return "'" . $escaped . "'";
    }

    /** Fallback CREATE TABLE inferred from the Fields designer config. */
    private function inferredCreateTable(string $tableName, array $fields): string
    {
        $sql = "-- Table: $tableName (inferred schema — project DB was unreachable at build time)\n";
        $sql .= "CREATE TABLE IF NOT EXISTS `$tableName` (\n";
        $sql .= "    `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,\n";

        foreach ($fields as $f) {
            if ($f['name'] === 'id') continue;
            $col = $f['name'];
            $type = $f['type'] ?? 'text';
            $nullable = ($f['required'] ?? false) ? 'NOT NULL' : 'DEFAULT NULL';

            $sqlType = match ($type) {
                'number', 'int', 'integer' => 'INT',
                'currency', 'float', 'double' => 'DECIMAL(12,2)',
                'boolean', 'bool' => 'TINYINT(1)',
                'date' => 'DATE',
                'datetime', 'timestamp' => 'DATETIME',
                'textarea', 'text' => 'TEXT',
                'json' => 'JSON',
                'email' => 'VARCHAR(255)',
                default => 'VARCHAR(255)',
            };

            $sql .= "    `$col` $sqlType $nullable,\n";
        }

        $sql .= "    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,\n";
        $sql .= "    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n";
        return $sql . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n\n";
    }

    /** Write INSTALL.md — step-by-step deployment instructions for the bundle. */
    public function generateInstallGuide(string $outputDir, string $slug, array $dbConfig, array $outputConfig, array $eventsState): array
    {
        $this->log('Writing INSTALL.md…');
        $dbName = (string) ($dbConfig['name'] ?? $slug);
        $baseUrl = trim((string) ($outputConfig['general']['baseUrl'] ?? ''));
        $baseNote = 'The app detects its own URL prefix at runtime, so it works at the web root '
            . '**or in any subfolder** — no reconfiguration or rebuild needed when you move it.';

        $cronTasks = array_values(array_filter(
            (array) ($eventsState['global']['schedule'] ?? []),
            static fn($t) => is_array($t) && ($t['enabled'] ?? true) !== false && trim((string) ($t['code'] ?? '')) !== ''
        ));
        $cronSection = $cronTasks === []
            ? "This project has no scheduled tasks.\n"
            : "This project has " . count($cronTasks) . " scheduled task(s). On the server, list them and add crontab entries:\n\n"
              . "```\nphp cron.php --list\ncrontab -e   # paste the suggested lines\n```\n";

        $installerNote = $this->standaloneInstaller ? <<<MD

## ⚡ Easiest: run the guided installer

This build ships a **standalone installer**. After uploading the files (step 1),
just open **`install.php`** in your browser and follow the wizard — it tests your
database connection, creates the tables, loads the starter data, and sets up your
administrator account. No `database.sql` import or config editing needed.

The installer **self-locks** when finished and offers to delete itself. For a hands-on
setup instead, follow the manual steps below.

---
MD : '';

        $md = <<<MD
# Deploying “{$slug}”

This folder is a complete, standalone build generated by PPphp. It has **no external
dependencies** — all CSS/JS assets are bundled locally.
{$installerNote}
## 1. Upload the files

Copy the entire contents of this folder to your web server's document root
(e.g. `/var/www/html` or `public_html`).

{$baseNote}

## 2. Create the database and seed data

Three SQL files ship with the build:

- **`schema.sql`** — every table **and view** (e.g. report views your dashboard
  needs), portable (no `DEFINER`), with **no** `CREATE DATABASE`/`USE`.
- **`seed.sql`** — the data (run **after** `schema.sql`).
- **`database.sql`** — the two combined **plus** a `CREATE DATABASE` statement, for
  a one-shot command-line import.

**Shared hosting (cPanel / DirectAdmin / phpMyAdmin) — recommended:** create your
database in the control panel, then in phpMyAdmin **select that database** and
*Import* **`schema.sql`**, then *Import* **`seed.sql`**. Because these files carry
no `USE` statement, they import into whatever database you selected — even when its
name differs from the development one (which it always does on shared hosting).

> ⚠️ Do **not** import `database.sql` in phpMyAdmin when your database has a
> different name — its `USE` line would target the wrong database. Use
> `schema.sql` + `seed.sql` there.

**Command line (creates the database for you):**

```
mysql -h YOUR_DB_HOST -u YOUR_DB_USER -p < database.sql
```

## 3. Point the app at your database

Edit `config/database.php` and set your production credentials:

```php
return [
    'host'   => 'localhost',      // your MySQL host
    'dbname' => '{$dbName}',
    'user'   => 'YOUR_DB_USER',
    'pass'   => 'YOUR_DB_PASSWORD',
    'port'   => 3306,
    'charset'=> 'utf8mb4',
];
```

## 4. Log in

Before building or deploying, open **Security → Database**, save the user-table
and field mappings, and use **Add admin user** to create an administrator with a
password known only to you. Open `generated/auth/login.php` and sign in with the
mapped username or email field. `database.sql` intentionally contains no account
with a known default password.

## 5. Scheduled tasks (cron)

{$cronSection}

## Requirements

- PHP 8.1+ with `pdo_mysql`
- MySQL 5.7+ / MariaDB 10.3+
- Apache with `mod_rewrite` (`.htaccess` included) or IIS (enable *Generate web.config* in the Output module)

## Troubleshooting

- **Blank page / 500:** check that `config/database.php` credentials are correct.
- **404 on every page:** ensure `mod_rewrite` is enabled and `.htaccess` is uploaded (hidden file!).
- **Wrong styling:** confirm the `assets/` folder uploaded completely.
MD;

        return [$this->writeFile($outputDir . '/INSTALL.md', $md . "\n")];
    }

    /**
     * Emit a self-contained install.php in the build root (Output → "Include
     * standalone installer"). It collects the database connection at runtime,
     * imports database.sql, creates the first administrator, and self-locks —
     * so a distributable bundle carries no credentials and no dev admin hash.
     */
    public function generateStandaloneInstaller(string $outputDir, string $slug, array $dbConfig, array $outputConfig): array
    {
        $stub = __DIR__ . '/stubs/standalone-installer.php';
        if (!is_file($stub)) {
            $this->log('Standalone installer requested but stub missing — skipped');
            return [];
        }
        $this->log('Writing standalone installer (install.php)…');

        $title = trim((string) ($outputConfig['general']['appName'] ?? '')) ?: ucwords(str_replace(['-', '_'], ' ', $slug));

        $code = strtr(file_get_contents($stub), [
            '{{SLUG}}'         => addslashes($slug),
            '{{APP_TITLE}}'    => addslashes($title),
            '{{GENERATED_AT}}' => date('c'),
        ]);

        return [$this->writeFile($outputDir . '/install.php', $code)];
    }

    private function writeFile(string $path, string $content): string
    {
        file_put_contents($path, $content);
        $this->filesWritten[] = $path;
        return $path;
    }

    /**
     * Phase 2 — emit the in-app live Translations editor + its API, only when the
     * project enabled "Live translation editing". Admin-gated, CSRF-guarded; edits
     * pp_translations (override layer) and pp_languages (runtime language list) so
     * an administrator localises the app in production with no rebuild.
     */
    public function generateLiveTranslations(string $outputDir): array
    {
        if (($this->ctx['miscProject']['liveTranslations'] ?? false) !== true) return [];
        $this->log('Generating live translations editor…');
        $written = [];
        $apiDir = $outputDir . '/api';
        if (!is_dir($apiDir)) mkdir($apiDir, 0755, true);
        $adminDir = $outputDir . '/generated/admin';
        if (!is_dir($adminDir)) mkdir($adminDir, 0755, true);

        // ── api/translations.php — admin+CSRF-gated read/write endpoint ──
        $api = <<<'TRAPI'
<?php
require_once __DIR__ . '/../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
header('Content-Type: application/json');

if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    http_response_code(403);
    echo json_encode(['success' => false, 'error' => 'Administrator access required.']);
    exit;
}
if (!function_exists('pp_i18n_live') || !pp_i18n_live()) {
    http_response_code(404);
    echo json_encode(['success' => false, 'error' => 'Live translations are not enabled for this app.']);
    exit;
}

use PpPhp\Core\I18n\TranslationStore;
TranslationStore::ensureSchema($GLOBALS['PP_I18N']['languages'] ?? []);

$catalogue = @include __DIR__ . '/../app/i18n-catalogue.php';
if (!is_array($catalogue)) $catalogue = [];

$do      = preg_replace('/[^a-z-]/', '', (string) ($_GET['do'] ?? $_POST['do'] ?? ''));
$isWrite = ($_SERVER['REQUEST_METHOD'] === 'POST');
$body    = $isWrite ? (json_decode((string) file_get_contents('php://input'), true) ?: $_POST) : [];

if ($isWrite) {
    $token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $body['_token'] ?? '');
    if (!\PpPhp\Core\Security::CSRF()->Check($token)) {
        http_response_code(419);
        echo json_encode(['success' => false, 'error' => 'Your session expired. Reload the page and try again.']);
        exit;
    }
}

// Map an editor (section,key) to the full dictionary key pp_t() resolves.
$dictKey = static function (string $section, string $key): ?string {
    $key = trim($key);
    if ($key === '') return null;
    switch ($section) {
        case 'ui':         return 'ui.' . $key;
        case 'menu':       return 'menu.' . $key;
        case 'tables':     return 'table.' . $key . '.caption';
        case 'fields':     return 'label.' . $key;   // key is "table.field"
        case 'pageTitles': return 'page.' . $key;    // key is "table.type"
        case 'userNames':  return 'user.' . $key;    // key is a name slug
        case 'content':    return 'content.' . $key; // key is the authored source text
        default:           return null;
    }
};

// A flat [section][key] => English-reference map from the baked catalogue, used
// by the auto-translate "Generate defaults" action to know the source text.
$catalogueRefs = static function (array $cat): array {
    $refs = ['ui' => [], 'menu' => [], 'tables' => [], 'fields' => [], 'pageTitles' => [], 'userNames' => [], 'content' => []];
    foreach ((array) ($cat['ui'] ?? []) as $group) {
        foreach ((array) $group as $k => $en) $refs['ui'][$k] = (string) $en;
    }
    foreach ((array) ($cat['menu'] ?? []) as $id => $lbl) $refs['menu'][$id] = (string) $lbl;
    foreach ((array) ($cat['tables'] ?? []) as $t => $cap) $refs['tables'][$t] = (string) $cap;
    foreach ((array) ($cat['fields'] ?? []) as $t => $flds) {
        foreach ((array) $flds as $f => $lbl) $refs['fields'][$t . '.' . $f] = (string) $lbl;
    }
    foreach ((array) ($cat['pageTitles'] ?? []) as $t => $types) {
        foreach ((array) $types as $ty => $lbl) $refs['pageTitles'][$t . '.' . $ty] = (string) $lbl;
    }
    foreach ((array) ($cat['userNames'] ?? []) as $slug => $nm) $refs['userNames'][$slug] = (string) $nm;
    foreach ((array) ($cat['content'] ?? []) as $src => $ref) $refs['content'][(string) $src] = (string) $ref;
    return $refs;
};

// ── Free online machine translation (for "Generate defaults") ──────────────
// Best-effort, server-side, no API key: Google's public "gtx" endpoint first,
// MyMemory as a fallback. Returns null on any failure so the caller can skip.
if (!function_exists('pp_tr_fetch')) {
    function pp_tr_fetch(string $url): ?string
    {
        try {
            if (function_exists('curl_init')) {
                $ch = curl_init($url);
                curl_setopt_array($ch, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_TIMEOUT        => 8,
                    CURLOPT_CONNECTTIMEOUT => 5,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_SSL_VERIFYPEER => false,
                    CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; PPPHP-Translate/1.0)',
                ]);
                $res = curl_exec($ch);
                $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                // curl_close() is a deprecated no-op since PHP 8.0 — omit it so a
                // deprecation notice can never leak in front of the JSON response.
                return ($res !== false && $code >= 200 && $code < 300) ? (string) $res : null;
            }
            $ctx = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: Mozilla/5.0\r\n"], 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]);
            $res = @file_get_contents($url, false, $ctx);
            return $res !== false ? (string) $res : null;
        } catch (\Throwable $e) {
            return null;
        }
    }
}
if (!function_exists('pp_tr_code')) {
    // Map project language codes to translator ISO codes where they differ.
    function pp_tr_code(string $c): string
    {
        $map = ['np' => 'ne', 'zh' => 'zh-CN', 'in' => 'id', 'us' => 'en'];
        $c = strtolower($c);
        return $map[$c] ?? $c;
    }
}
if (!function_exists('pp_tr_translate')) {
    function pp_tr_translate(string $text, string $to, string $from = 'en'): ?string
    {
        $text = trim($text);
        if ($text === '') return null;
        $to = pp_tr_code($to);
        $from = pp_tr_code($from);
        // 1) Google gtx
        $url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' . rawurlencode($from)
             . '&tl=' . rawurlencode($to) . '&dt=t&q=' . rawurlencode($text);
        $raw = pp_tr_fetch($url);
        if ($raw !== null) {
            $data = json_decode($raw, true);
            if (is_array($data) && isset($data[0]) && is_array($data[0])) {
                $out = '';
                foreach ($data[0] as $chunk) {
                    if (is_array($chunk) && isset($chunk[0])) $out .= (string) $chunk[0];
                }
                $out = trim($out);
                if ($out !== '') return $out;
            }
        }
        // 2) MyMemory fallback
        $url2 = 'https://api.mymemory.translated.net/get?q=' . rawurlencode($text)
              . '&langpair=' . rawurlencode($from . '|' . $to);
        $raw2 = pp_tr_fetch($url2);
        if ($raw2 !== null) {
            $d2 = json_decode($raw2, true);
            $t2 = trim((string) ($d2['responseData']['translatedText'] ?? ''));
            // MyMemory echoes an error sentence in translatedText on quota/failure.
            if ($t2 !== '' && stripos($t2, 'MYMEMORY WARNING') === false && stripos($t2, 'INVALID') === false) {
                return $t2;
            }
        }
        return null;
    }
}

$langCodes = array_column(pp_languages(), 'code');

// Account display names (from the live users table) → editable "user.<slug>" keys
// so a role-style name like "Administrator" can follow the app language too.
$ppSlug = static function (string $s): string {
    return preg_replace('/[^a-z0-9]+/', '_', strtolower(trim($s))) ?: '';
};
$userNames = [];
$__me = \PpPhp\Core\Security::CurrentUserData() ?: [];
$__meName = trim((string) ($__me['fullname'] ?? $__me['username'] ?? ''));
if ($__meName !== '') $userNames[$ppSlug($__meName)] = $__meName;
try {
    foreach ((array) \PpPhp\Core\DB::FetchAll('SELECT * FROM users') as $__r) {
        $__nm = trim((string) ($__r['fullname'] ?? $__r['name'] ?? $__r['username'] ?? ''));
        if ($__nm !== '') $userNames[$ppSlug($__nm)] = $__nm;
    }
} catch (\Throwable $e) {}
unset($userNames['']);
$catalogue['userNames'] = $userNames;

if ($do === 'load') {
    $lang = (string) ($_GET['lang'] ?? '');
    echo json_encode([
        'success'   => true,
        'catalogue' => $catalogue,
        'overrides' => ($lang !== '') ? TranslationStore::overridesFor($lang) : [],
        'baked'     => $GLOBALS['PP_I18N']['dict'][$lang] ?? [],
        'languages' => TranslationStore::allLanguages(),
        'default'   => $GLOBALS['PP_I18N']['default'] ?? 'en',
    ]);
    exit;
}

if ($do === 'save' && $isWrite) {
    $lang    = (string) ($body['lang'] ?? '');
    $section = (string) ($body['section'] ?? '');
    $key     = (string) ($body['key'] ?? '');
    $value   = (string) ($body['value'] ?? '');
    if (!in_array($lang, $langCodes, true)) {
        http_response_code(400);
        echo json_encode(['success' => false, 'error' => 'Unknown language.']);
        exit;
    }
    $dk = $dictKey($section, $key);
    if ($dk === null) {
        http_response_code(400);
        echo json_encode(['success' => false, 'error' => 'Unknown string.']);
        exit;
    }
    echo json_encode(['success' => TranslationStore::set($lang, $dk, $value)]);
    exit;
}

if ($do === 'add-lang' && $isWrite) {
    $ok = TranslationStore::saveLanguage(
        (string) ($body['code'] ?? ''),
        (string) ($body['name'] ?? ''),
        (string) ($body['flag'] ?? '') !== '' ? (string) $body['flag'] : '🌐'
    );
    echo json_encode(['success' => $ok, 'languages' => TranslationStore::allLanguages()]);
    exit;
}

if ($do === 'save-lang' && $isWrite) {
    $ok = TranslationStore::saveLanguage(
        (string) ($body['code'] ?? ''),
        (string) ($body['name'] ?? ''),
        (string) ($body['flag'] ?? ''),
        isset($body['sort']) ? (int) $body['sort'] : null,
        ($body['enabled'] ?? true) !== false
    );
    echo json_encode(['success' => $ok, 'languages' => TranslationStore::allLanguages()]);
    exit;
}

if ($do === 'remove-lang' && $isWrite) {
    $ok = TranslationStore::removeLanguage((string) ($body['code'] ?? ''));
    echo json_encode(['success' => $ok, 'languages' => TranslationStore::allLanguages()]);
    exit;
}

// Auto-translate empty strings for a language using a free online translator.
// Only fills strings with NO override and NO baked translation; batched (the
// client loops while progress + remaining). Values are machine-generated — the
// admin can overwrite any of them afterwards.
if ($do === 'autofill' && $isWrite) {
    $lang    = (string) ($body['lang'] ?? '');
    $section = (string) ($body['section'] ?? '');
    $limit   = max(1, min(80, (int) ($body['limit'] ?? 40)));
    if (!in_array($lang, $langCodes, true)) {
        http_response_code(400);
        echo json_encode(['success' => false, 'error' => 'Unknown language.']);
        exit;
    }
    $default = $GLOBALS['PP_I18N']['default'] ?? 'en';
    if ($lang === $default) {
        echo json_encode(['success' => true, 'filled' => 0, 'remaining' => 0, 'failed' => 0, 'overrides' => [], 'message' => 'This is the primary language — nothing to translate.']);
        exit;
    }
    $refs = $catalogueRefs($catalogue);
    $sectionRefs = $refs[$section] ?? null;
    if (!is_array($sectionRefs)) {
        http_response_code(400);
        echo json_encode(['success' => false, 'error' => 'Unknown section.']);
        exit;
    }
    $overrides = TranslationStore::overridesFor($lang);
    $baked     = $GLOBALS['PP_I18N']['dict'][$lang] ?? [];
    $filled = 0; $remaining = 0; $failed = 0; $updated = [];
    foreach ($sectionRefs as $key => $src) {
        $dk = $dictKey($section, (string) $key);
        if ($dk === null) continue;
        // Skip anything that already has a value (override or bundled/baked).
        if (($overrides[$dk] ?? '') !== '' || ($baked[$dk] ?? '') !== '') continue;
        $src = trim((string) $src);
        if ($src === '') continue;
        // Never machine-translate strings with %placeholder% format tokens — the
        // translator mangles the tokens (e.g. "%total%" → "% कुल %"), breaking the
        // substitution. Leave them for manual translation.
        if (preg_match('/%\w+%/', $src)) continue;
        if ($filled + $failed >= $limit) { $remaining++; continue; }
        $tr = pp_tr_translate($src, $lang, $default);
        if ($tr !== null && $tr !== '') {
            if (TranslationStore::set($lang, $dk, $tr)) { $updated[$dk] = $tr; $filled++; }
            else { $failed++; }
        } else {
            $failed++;
        }
    }
    echo json_encode([
        'success'   => true,
        'filled'    => $filled,
        'remaining' => $remaining,
        'failed'    => $failed,
        'overrides' => $updated,
        'message'   => ($filled === 0 && $failed > 0)
            ? 'Could not reach the translation service. Check the server\'s internet access and try again.'
            : '',
    ]);
    exit;
}

http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Unknown action.']);
TRAPI;
        $written[] = $this->writeFile($apiDir . '/translations.php', $api . "\n");

        // ── generated/admin/translations.php — the editor page ──
        $page = <<<'TRPAGE'
<?php
require_once __DIR__ . '/../../_runtime.php';
\PpPhp\Core\Security::RequireAuth();
if (!function_exists('pp_i18n_live') || !pp_i18n_live()) {
    header('Location: ' . APP_BASE . '/'); exit;
}
if (!\PpPhp\Core\Auth\Permissions::isAdmin()) {
    $_SESSION['_flash'] = ['message' => 'Translations are available to administrators only.', 'type' => 'danger'];
    header('Location: ' . APP_BASE . '/generated/auth/no-access.php'); exit;
}
$csrf = \PpPhp\Core\Security::CSRF()->Token();
?><!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Translations</title>
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/icons/bootstrap-icons.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/vendor/inter/inter.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/theme.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/shell.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.css">
<link rel="stylesheet" href="<?= APP_BASE ?>/assets/css/app.css">
<style>
.ppt-wrap{max-width:1100px;margin:1.4rem auto;padding:0 1rem}
.ppt-head{display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start;justify-content:space-between;margin-bottom:1rem}
.ppt-title{font-size:1.5rem;font-weight:700;margin:0;display:flex;align-items:center;gap:.5rem}
.ppt-sub{color:var(--pp-muted,#64748b);margin:.25rem 0 0;font-size:.9rem}
.ppt-exit{white-space:nowrap}
.ppt-langbar{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-bottom:1rem}
.ppt-lang{border:1px solid var(--pp-border,#e2e8f0);background:var(--pp-surface,#fff);color:var(--pp-text,#0f172a);border-radius:999px;padding:.3rem .8rem;cursor:pointer;font-size:.85rem;display:inline-flex;gap:.4rem;align-items:center}
.ppt-lang.is-active{background:var(--pp-primary,#6366f1);color:#fff;border-color:transparent}
.ppt-lang.is-off{opacity:.45}
.ppt-tabs{display:flex;flex-wrap:wrap;gap:.3rem;border-bottom:1px solid var(--pp-border,#e2e8f0);margin-bottom:.9rem}
.ppt-tab{border:none;background:none;padding:.5rem .85rem;cursor:pointer;color:var(--pp-muted,#64748b);border-bottom:2px solid transparent;font-size:.9rem}
.ppt-tab.is-active{color:var(--pp-primary,#6366f1);border-bottom-color:var(--pp-primary,#6366f1);font-weight:600}
.ppt-toolbar{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center;margin-bottom:.7rem}
.ppt-search{flex:1;min-width:180px}
table.ppt-grid{width:100%;border-collapse:collapse;font-size:.87rem}
table.ppt-grid th,table.ppt-grid td{border:1px solid var(--pp-border,#e2e8f0);padding:.4rem .55rem;vertical-align:middle;text-align:left}
table.ppt-grid thead th{background:var(--pp-surface-2,#f8fafc);font-weight:600}
.ppt-grp td{background:var(--pp-surface-2,#f8fafc);font-weight:600;text-transform:uppercase;font-size:.7rem;letter-spacing:.03em;color:var(--pp-muted,#64748b)}
.ppt-ref{width:42%;color:var(--pp-text,#0f172a)}
.ppt-in{width:100%;border:1px solid var(--pp-border,#e2e8f0);border-radius:6px;padding:.32rem .5rem;background:var(--pp-input-bg,#fff);color:var(--pp-input-text,#0f172a)}
.ppt-in.is-saved{border-color:#16a34a;box-shadow:0 0 0 2px #16a34a22}
.ppt-empty{color:var(--pp-muted,#64748b);padding:1.4rem;text-align:center}
.ppt-mgr{border:1px solid var(--pp-border,#e2e8f0);border-radius:10px;padding:.9rem;margin-bottom:1rem;background:var(--pp-surface,#fff)}
.ppt-mgr h3{font-size:.95rem;margin:0 0 .6rem}
.ppt-mgr-row{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-bottom:.4rem}
.ppt-note{font-size:.8rem;color:var(--pp-muted,#64748b);margin-top:.5rem}
</style>
</head>
<body>
<?php pp_shell_start('Translations', '', ''); ?>
<div class="ppt-wrap">
  <div class="ppt-head">
    <div>
      <h1 class="ppt-title"><i class="bi bi-translate"></i> Translations</h1>
      <p class="ppt-sub">Translate the interface and manage languages — changes are live immediately, no rebuild. Blank fields fall back to the built-in text.</p>
    </div>
    <a href="<?= APP_BASE ?>/" class="btn btn-sm btn-outline-secondary ppt-exit"><i class="bi bi-box-arrow-left"></i> Back to app</a>
  </div>

  <details class="ppt-mgr">
    <summary style="cursor:pointer;font-weight:600"><i class="bi bi-globe2"></i> Manage languages</summary>
    <div id="pptLangMgr" style="margin-top:.7rem"></div>
    <div class="ppt-mgr-row" style="margin-top:.7rem">
      <input id="pptNewCode" class="form-control form-control-sm" style="max-width:110px" placeholder="code (e.g. fr)">
      <input id="pptNewName" class="form-control form-control-sm" style="max-width:180px" placeholder="Name (e.g. Français)">
      <input id="pptNewFlag" class="form-control form-control-sm" style="max-width:70px" placeholder="🏳️">
      <button id="pptAddLang" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> Add language</button>
    </div>
    <div class="ppt-note">Removing a language deletes its translations. At least one language always remains.</div>
  </details>

  <div class="ppt-langbar" id="pptLangbar"></div>
  <div class="ppt-tabs" id="pptTabs"></div>
  <div class="ppt-toolbar">
    <input type="text" id="pptSearch" class="form-control form-control-sm ppt-search" placeholder="🔍 Filter strings…">
    <button id="pptAutofill" class="btn btn-sm btn-outline-primary" title="Machine-translate the empty fields in this tab using a free online translation service. You can overwrite any result."><i class="bi bi-magic"></i> Generate defaults</button>
    <span class="ppt-note" id="pptStatus"></span>
  </div>
  <div id="pptGrid"></div>
</div>
<?php pp_shell_end(); ?>
<script>
window.PP_BASE = '<?= APP_BASE ?>';
window.PP_CSRF = '<?= htmlspecialchars($csrf, ENT_QUOTES) ?>';
</script>
<script src="<?= APP_BASE ?>/assets/jquery/jquery.min.js"></script>
<script src="<?= APP_BASE ?>/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="<?= APP_BASE ?>/assets/sweetalert2/sweetalert2.min.js"></script>
<script>
(function(){
  var API = (window.PP_BASE||'') + '/api/translations.php';
  var esc = function(s){ return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); };
  var hasSwal = typeof window.Swal !== 'undefined';
  // App-styled confirm / alert (SweetAlert2) with a graceful native fallback.
  function ppConfirm(text, title, confirmText){
    if(!hasSwal) return Promise.resolve(window.confirm((title?title+'\n\n':'') + text));
    return window.Swal.fire({title:title||'Are you sure?', text:text, icon:'question', showCancelButton:true,
      confirmButtonText:confirmText||'Yes', cancelButtonText:'Cancel', reverseButtons:true,
      confirmButtonColor:'var(--pp-primary,#6366f1)'}).then(function(r){return !!r.isConfirmed;});
  }
  function ppAlert(text, icon){
    if(!hasSwal){ window.alert(text); return Promise.resolve(); }
    return window.Swal.fire({text:text, icon:icon||'info', confirmButtonText:'OK', confirmButtonColor:'var(--pp-primary,#6366f1)'});
  }
  var SECTIONS = [
    {id:'ui',         label:'App Strings'},
    {id:'menu',       label:'Menu'},
    {id:'tables',     label:'Tables'},
    {id:'fields',     label:'Fields'},
    {id:'pageTitles', label:'Page Titles'},
    {id:'content',    label:'Content'},
    {id:'userNames',  label:'Account Names'}
  ];
  var state = { lang:null, section:'ui', catalogue:{}, overrides:{}, baked:{}, languages:[], def:'en' };

  function post(payload){
    return fetch(API + '?do=' + encodeURIComponent(payload.do), {
      method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':window.PP_CSRF},
      body: JSON.stringify(payload)
    }).then(function(r){return r.json();});
  }
  function load(lang){
    var url = API + '?do=load' + (lang ? '&lang=' + encodeURIComponent(lang) : '');
    return fetch(url).then(function(r){return r.json();});
  }

  function renderLangbar(){
    var bar = document.getElementById('pptLangbar');
    bar.innerHTML = '';
    (state.languages||[]).forEach(function(l){
      var b = document.createElement('button');
      b.className = 'ppt-lang' + (l.code===state.lang?' is-active':'') + (Number(l.is_enabled)===0?' is-off':'');
      b.innerHTML = (l.flag?esc(l.flag)+' ':'') + esc(l.name) + ' <span style="opacity:.6">('+esc(l.code)+')</span>';
      b.onclick = function(){ switchLang(l.code); };
      bar.appendChild(b);
    });
  }
  function renderLangMgr(){
    var box = document.getElementById('pptLangMgr');
    box.innerHTML = '';
    (state.languages||[]).forEach(function(l){
      var row = document.createElement('div'); row.className='ppt-mgr-row';
      row.innerHTML =
        '<span style="min-width:150px">'+(l.flag?esc(l.flag)+' ':'')+esc(l.name)+' <code>'+esc(l.code)+'</code></span>'+
        '<label style="font-size:.8rem;display:inline-flex;gap:.3rem;align-items:center"><input type="checkbox" '+(Number(l.is_enabled)!==0?'checked':'')+' data-en="'+esc(l.code)+'"> enabled</label>'+
        '<button class="btn btn-sm btn-outline-danger" data-rm="'+esc(l.code)+'"><i class="bi bi-trash"></i></button>';
      box.appendChild(row);
    });
    box.querySelectorAll('[data-en]').forEach(function(cb){
      cb.onchange = function(){
        var l = state.languages.find(function(x){return x.code===cb.getAttribute('data-en');});
        post({do:'save-lang', code:cb.getAttribute('data-en'), name:l.name, flag:l.flag||'', enabled:cb.checked, _token:window.PP_CSRF})
          .then(function(res){ if(res.languages){ state.languages=res.languages; renderLangbar(); } });
      };
    });
    box.querySelectorAll('[data-rm]').forEach(function(btn){
      btn.onclick = function(){
        ppConfirm('Remove this language and all its translations? This cannot be undone.', 'Remove language', 'Remove').then(function(ok){
          if(!ok) return;
          post({do:'remove-lang', code:btn.getAttribute('data-rm'), _token:window.PP_CSRF}).then(function(res){
            if(res.success){ state.languages=res.languages||[]; if(!state.languages.some(function(l){return l.code===state.lang;})) state.lang=(state.languages[0]||{}).code; renderLangbar(); renderLangMgr(); switchLang(state.lang); }
            else ppAlert(res.error||'Could not remove.', 'error');
          });
        });
      };
    });
  }
  function renderTabs(){
    var t = document.getElementById('pptTabs'); t.innerHTML='';
    SECTIONS.forEach(function(s){
      var b=document.createElement('button');
      b.className='ppt-tab'+(s.id===state.section?' is-active':'');
      b.textContent=s.label; b.onclick=function(){ state.section=s.id; renderTabs(); renderGrid(); };
      t.appendChild(b);
    });
  }

  function rows(){
    // → [{group?, ref, section, key, dictKey}]
    var out=[], c=state.catalogue||{};
    if(state.section==='ui'){
      var g=c.ui||{};
      Object.keys(g).forEach(function(grp){
        out.push({group:grp});
        Object.keys(g[grp]||{}).forEach(function(k){ out.push({ref:g[grp][k], section:'ui', key:k, dictKey:'ui.'+k}); });
      });
    } else if(state.section==='menu'){
      var m=c.menu||{};
      Object.keys(m).forEach(function(id){ out.push({ref:m[id], section:'menu', key:id, dictKey:'menu.'+id}); });
    } else if(state.section==='tables'){
      var tb=c.tables||{};
      Object.keys(tb).forEach(function(t){ out.push({ref:tb[t], section:'tables', key:t, dictKey:'table.'+t+'.caption'}); });
    } else if(state.section==='fields'){
      var f=c.fields||{};
      Object.keys(f).forEach(function(t){
        out.push({group:(c.tables&&c.tables[t])||t});
        Object.keys(f[t]||{}).forEach(function(fld){ out.push({ref:f[t][fld], section:'fields', key:t+'.'+fld, dictKey:'label.'+t+'.'+fld}); });
      });
    } else if(state.section==='pageTitles'){
      var p=c.pageTitles||{};
      Object.keys(p).forEach(function(t){
        out.push({group:(c.tables&&c.tables[t])||t});
        Object.keys(p[t]||{}).forEach(function(ty){ out.push({ref:p[t][ty], section:'pageTitles', key:t+'.'+ty, dictKey:'page.'+t+'.'+ty}); });
      });
    } else if(state.section==='content'){
      var ct=c.content||{};
      var ck=Object.keys(ct);
      if(!ck.length){ return [{group:'No component text found — add headings/labels on the Designer canvas.'}]; }
      ck.forEach(function(src){ out.push({ref:ct[src], section:'content', key:src, dictKey:'content.'+src}); });
    } else if(state.section==='userNames'){
      var u=c.userNames||{};
      var uk=Object.keys(u);
      if(!uk.length){ return [{group:'No account names found'}]; }
      uk.forEach(function(slug){ out.push({ref:u[slug], section:'userNames', key:slug, dictKey:'user.'+slug}); });
    }
    return out;
  }

  function renderGrid(){
    var host=document.getElementById('pptGrid');
    var data=rows();
    if(!data.length){ host.innerHTML='<div class="ppt-empty">Nothing to translate in this section.</div>'; return; }
    var langName=(state.languages.find(function(l){return l.code===state.lang;})||{}).name||state.lang;
    var html='<table class="ppt-grid"><thead><tr><th class="ppt-ref">Interface text (default)</th><th>'+esc(langName)+'</th></tr></thead><tbody>';
    data.forEach(function(r){
      if(r.group!==undefined){ html+='<tr class="ppt-grp"><td colspan="2">'+esc(r.group)+'</td></tr>'; return; }
      var cur=state.overrides[r.dictKey]||'';
      var ph=state.baked[r.dictKey]||r.ref||'';
      html+='<tr data-search="'+esc((r.ref+' '+r.key).toLowerCase())+'">'+
        '<td class="ppt-ref">'+esc(r.ref||r.key)+'</td>'+
        '<td><input class="ppt-in" data-section="'+esc(r.section)+'" data-key="'+esc(r.key)+'" data-dk="'+esc(r.dictKey)+'" placeholder="'+esc(ph)+'" value="'+esc(cur)+'"></td></tr>';
    });
    html+='</tbody></table>';
    host.innerHTML=html;
    host.querySelectorAll('.ppt-in').forEach(function(inp){
      inp.addEventListener('blur', function(){ saveInput(inp); });
    });
    applySearch();
  }

  function saveInput(inp){
    var val=inp.value;
    post({do:'save', lang:state.lang, section:inp.getAttribute('data-section'), key:inp.getAttribute('data-key'), value:val, _token:window.PP_CSRF})
      .then(function(res){
        if(res.success){
          var dk=inp.getAttribute('data-dk');
          if(val==='') delete state.overrides[dk]; else state.overrides[dk]=val;
          inp.classList.add('is-saved'); setTimeout(function(){inp.classList.remove('is-saved');},900);
          status('Saved.');
        } else { status(res.error||'Save failed.'); }
      }).catch(function(){ status('Save failed.'); });
  }
  function status(m){ var s=document.getElementById('pptStatus'); s.textContent=m; if(m){ setTimeout(function(){ if(s.textContent===m) s.textContent=''; },1600); } }

  function applySearch(){
    var q=(document.getElementById('pptSearch').value||'').toLowerCase();
    document.querySelectorAll('#pptGrid tbody tr[data-search]').forEach(function(tr){
      tr.style.display=(!q||tr.getAttribute('data-search').indexOf(q)>-1)?'':'none';
    });
  }

  function switchLang(code){
    if(!code) return; state.lang=code;
    load(code).then(function(d){
      if(!d||!d.success) return;
      state.catalogue=d.catalogue||{}; state.overrides=d.overrides||{}; state.baked=d.baked||{};
      state.languages=d.languages||state.languages; state.def=d.default||'en';
      renderLangbar(); renderGrid();
    });
  }

  document.getElementById('pptSearch').addEventListener('input', applySearch);
  document.getElementById('pptAddLang').addEventListener('click', function(){
    var code=(document.getElementById('pptNewCode').value||'').trim();
    var name=(document.getElementById('pptNewName').value||'').trim();
    var flag=(document.getElementById('pptNewFlag').value||'').trim();
    if(!code||!name){ ppAlert('Enter a language code and a name.', 'warning'); return; }
    post({do:'add-lang', code:code, name:name, flag:flag, _token:window.PP_CSRF}).then(function(res){
      if(res.success){ state.languages=res.languages||[]; document.getElementById('pptNewCode').value=''; document.getElementById('pptNewName').value=''; document.getElementById('pptNewFlag').value=''; renderLangbar(); renderLangMgr(); }
      else ppAlert(res.error||'Could not add language.', 'error');
    });
  });

  // ── Generate defaults: machine-translate the empty fields in this tab ──
  var autofillBusy = false;
  function runAutofill(){
    if(autofillBusy) return;
    var secLabel=(SECTIONS.find(function(s){return s.id===state.section;})||{}).label||state.section;
    var langName=(state.languages.find(function(l){return l.code===state.lang;})||{}).name||state.lang;
    if(state.lang===state.def){ ppAlert('“'+langName+'” is the primary language — there is nothing to translate into it.'); return; }
    ppConfirm('Auto-translate the empty “'+secLabel+'” fields into '+langName+' using a free online translation service? Only blank fields are filled — your existing translations are kept, and you can overwrite any result afterwards.', 'Generate defaults', 'Translate').then(function(ok){
      if(!ok) return;
      startAutofill();
    });
  }
  function startAutofill(){
    var btn=document.getElementById('pptAutofill');
    autofillBusy=true; btn.disabled=true;
    var original=btn.innerHTML; btn.innerHTML='<span class="spinner-border spinner-border-sm"></span> Translating…';
    var total=0;
    function pass(){
      post({do:'autofill', lang:state.lang, section:state.section, limit:40, _token:window.PP_CSRF}).then(function(res){
        if(!res||!res.success){ finish(res&&res.error||'Translation failed.'); return; }
        Object.keys(res.overrides||{}).forEach(function(dk){ state.overrides[dk]=res.overrides[dk]; });
        total+=res.filled||0;
        if(res.message){ finish(res.message); return; }
        status('Translated '+total+'…');
        renderGrid();
        // Keep going while there is more AND we are making progress.
        if((res.remaining||0)>0 && (res.filled||0)>0){ pass(); }
        else { finish((total>0?('Filled '+total+' string'+(total===1?'':'s')+'.'):'No empty fields to fill.') + ((res.failed||0)>0?' ('+res.failed+' could not be translated.)':'')); }
      }).catch(function(){ finish('Translation request failed.'); });
    }
    function finish(msg){ autofillBusy=false; btn.disabled=false; btn.innerHTML=original; renderGrid(); status(msg); }
    pass();
  }
  document.getElementById('pptAutofill').addEventListener('click', runAutofill);

  // Boot
  load(null).then(function(d){
    if(!d||!d.success){ document.getElementById('pptGrid').innerHTML='<div class="ppt-empty">Could not load translations.</div>'; return; }
    state.catalogue=d.catalogue||{}; state.languages=d.languages||[]; state.def=d.default||'en';
    state.lang=(state.languages[0]||{}).code||state.def;
    renderTabs(); renderLangbar(); renderLangMgr();
    switchLang(state.lang);
  });
})();
</script>
</body></html>
TRPAGE;
        $written[] = $this->writeFile($adminDir . '/translations.php', $page . "\n");

        return $written;
    }

    /**
     * Generate app/lang.php — the multi-language runtime built from the Misc
     * module's languages + labels: pp_t() for UI strings, pp_table_caption()
     * and pp_page_title() for per-language overrides.
     */
    public function generateLang(string $outputDir): array
    {
        $this->log('Generating language runtime…');
        $appDir = $outputDir . '/app';
        if (!is_dir($appDir)) mkdir($appDir, 0755, true);

        $languages = array_values(array_filter(
            (array) ($this->ctx['languages'] ?? []),
            static fn($l) => is_array($l) && !empty($l['code'])
        ));
        if ($languages === []) {
            $languages = [['code' => 'en', 'name' => 'English']];
        }

        // Country flag per language (emoji — no assets needed); explicit 'flag' wins
        $flagMap = [
            'en' => '🇬🇧', 'us' => '🇺🇸', 'np' => '🇳🇵', 'ne' => '🇳🇵', 'hi' => '🇮🇳', 'in' => '🇮🇳',
            'es' => '🇪🇸', 'fr' => '🇫🇷', 'de' => '🇩🇪', 'it' => '🇮🇹', 'pt' => '🇵🇹', 'ru' => '🇷🇺',
            'zh' => '🇨🇳', 'ja' => '🇯🇵', 'ko' => '🇰🇷', 'ar' => '🇸🇦', 'tr' => '🇹🇷', 'nl' => '🇳🇱',
            'th' => '🇹🇭', 'vi' => '🇻🇳', 'id' => '🇮🇩', 'bn' => '🇧🇩', 'ur' => '🇵🇰',
        ];
        foreach ($languages as &$lang) {
            if (empty($lang['flag'])) {
                $lang['flag'] = $flagMap[strtolower((string) $lang['code'])] ?? '🌐';
            }
        }
        unset($lang);
        $default = (string) $languages[0]['code'];

        // Built-in UI dictionary (English keys are the fallbacks)
        $dict = [];
        foreach ($languages as $lang) {
            $code = (string) $lang['code'];
            // Seed bundled UI-chrome translations (e.g. Nepali) from the single
            // catalogue so a project reads sensibly before anyone translates; the
            // Misc → Label Editor → App Strings values override these below.
            $dict[$code] = [];
            foreach (\PpPhp\Core\I18n\UiStrings::seed($code) as $__uk => $__uv) {
                $dict[$code]['ui.' . $__uk] = (string) $__uv;
            }
            // Merge per-language label overrides from the Misc Label Editor:
            // table captions, page titles, field placeholders, tooltips,
            // custom labels, and the cookie-consent text.
            $labels = (array) ($this->ctx['labels'][$code] ?? []);
            $sections = [
                'tables'       => 'table.',
                'pageTitles'   => 'page.',
                'labels'       => 'label.',
                'placeholders' => 'placeholder.',
                'tooltips'     => 'tooltip.',
                'custom'       => 'custom.',
                'ui'           => 'ui.',      // App Strings tab → chrome (ui.<key>)
                'menu'         => 'menu.',    // Menu tab → nav labels (menu.<key>)
                'content'      => 'content.', // Designer component text (pp_ct) → content.<sourceText>
            ];
            foreach ($sections as $section => $prefix) {
                foreach ((array) ($labels[$section] ?? []) as $key => $val) {
                    if (trim((string) $val) !== '') $dict[$code][$prefix . $key] = (string) $val;
                }
            }
            $cookie = trim((string) ($labels['cookie']['cookieBannerText'] ?? ''));
            if ($cookie !== '') $dict[$code]['cookie.banner'] = $cookie;
        }

        $langExport = var_export($languages, true);
        $dictExport = var_export($dict, true);
        $defaultExport = var_export($default, true);
        // Misc → Project → "Show language selector". Defaults ON (unset/true → show);
        // only an explicit false hides the navbar selector even with 2+ languages.
        $selectorExport = (($this->ctx['miscProject']['showLanguageSelector'] ?? true) !== false) ? 'true' : 'false';
        // Phase 2 — Misc → Project → "Live translation editing (database)". When on,
        // the app overlays a DB table over the baked dict + manages languages at
        // runtime, so an admin can translate in production with no rebuild.
        $liveExport = (($this->ctx['miscProject']['liveTranslations'] ?? false) === true) ? 'true' : 'false';

        // ── Bake the editable catalogue (app/i18n-catalogue.php) that drives the
        //    in-app Translations editor: every translatable key + its English
        //    reference, grouped by surface. The live editor stores overrides in
        //    pp_translations keyed by the FULL dict key (prefix + key). ─────────
        // Menu catalogue = the SAME fully-resolved menu the shell renders (designed
        // /default items + auto-injected Calendar), keyed by tkey so the editor keys
        // match pp_menu_html's pp_t('menu.<tkey>') exactly. Dividers carry no label.
        $menuFlat = [];
        $flattenMenu = function ($items) use (&$flattenMenu, &$menuFlat): void {
            foreach ((array) $items as $it) {
                if (!is_array($it) || !empty($it['divider'])) continue;
                $tkey = (string) ($it['tkey'] ?? '');
                $lbl  = (string) ($it['label'] ?? '');
                if ($tkey !== '' && $lbl !== '') $menuFlat[$tkey] = $lbl;
                if (!empty($it['children'])) $flattenMenu($it['children']);
            }
        };
        $flattenMenu($this->menuItemsResolved(array_keys((array) $this->tableFields)));
        $tableCaptions = [];
        $fieldLabels = [];
        $pageTitles = [];
        $pageTypeLabels = [
            'list' => 'List', 'add' => 'Add', 'edit' => 'Edit', 'view' => 'View',
            'print' => 'Print', 'export' => 'Export', 'import' => 'Import', 'cards' => 'Cards',
        ];
        foreach ((array) $this->tableFields as $tbl => $flds) {
            $tbl = (string) $tbl;
            if ($tbl === '') continue;
            $cap = (string) ($this->ctx['misc'][$tbl]['caption'] ?? '');
            $tableCaptions[$tbl] = $cap !== '' ? $cap : ucwords(str_replace('_', ' ', $tbl));
            foreach ((array) $flds as $f) {
                $fn = (string) ($f['name'] ?? '');
                if ($fn === '') continue;
                $fieldLabels[$tbl][$fn] = (string) ($f['label'] ?? '') !== ''
                    ? (string) $f['label'] : ucwords(str_replace('_', ' ', $fn));
            }
            // Per-page heading keys (pp_page_title → page.<table>.<type>). The English
            // reference is "<Caption> · <Page>" so a translator knows which screen.
            foreach ($pageTypeLabels as $ptype => $plabel) {
                $pageTitles[$tbl][$ptype] = $tableCaptions[$tbl] . ' · ' . $plabel;
            }
        }
        $catalogue = [
            'ui'         => \PpPhp\Core\I18n\UiStrings::groups(),
            'menu'       => $menuFlat,
            'tables'     => $tableCaptions,
            'fields'     => $fieldLabels,
            'pageTitles' => $pageTitles,
        ];
        $catalogueExport = var_export($catalogue, true);
        $catalogueContent = "<?php\n\ndeclare(strict_types=1);\n\n"
            . "// Editable-string catalogue for the in-app Translations editor (Phase 2).\n"
            . "// Auto-generated from the Misc labels + designer. Keys map to dict keys via\n"
            . "// section prefixes: ui.<k>, menu.<id>, table.<t>.caption, label.<t>.<f>, page.<t>.<type>.\n"
            . "return {$catalogueExport};\n";

        $content = <<<LANGPHP
<?php

declare(strict_types=1);

/**
 * Multi-language runtime — generated from the Misc module.
 */

\$GLOBALS['PP_I18N'] = [
    'default'   => {$defaultExport},
    'languages' => {$langExport},
    'dict'      => {$dictExport},
    'selector'  => {$selectorExport},
    'live'      => {$liveExport},
];

/** True when live (database) translation editing is enabled for this app. */
function pp_i18n_live(): bool
{
    return !empty(\$GLOBALS['PP_I18N']['live']);
}

/**
 * Lazy one-time boot for live mode: ensure the translation tables exist and let
 * the runtime language list (pp_languages) come from the database so languages
 * added in production appear immediately. Fully guarded — any failure keeps the
 * baked layer. Runs once, on first pp_languages()/pp_t() call (DB is up by then).
 */
function pp_i18n_boot(): void
{
    static \$done = false;
    if (\$done) return;
    \$done = true;
    if (!pp_i18n_live() || !class_exists('\\\\PpPhp\\\\Core\\\\I18n\\\\TranslationStore')) return;
    try {
        \PpPhp\Core\I18n\TranslationStore::ensureSchema(\$GLOBALS['PP_I18N']['languages']);
        \$live = \PpPhp\Core\I18n\TranslationStore::languages();
        if (is_array(\$live) && \$live !== []) {
            // Preserve baked flags for languages that predate the DB row.
            \$bakedFlags = array_column(\$GLOBALS['PP_I18N']['languages'], 'flag', 'code');
            foreach (\$live as &\$l) {
                if ((string) (\$l['flag'] ?? '') === '' && isset(\$bakedFlags[\$l['code']])) {
                    \$l['flag'] = \$bakedFlags[\$l['code']];
                }
            }
            unset(\$l);
            \$GLOBALS['PP_I18N']['languages'] = \$live;
        }
    } catch (\Throwable \$e) {
        // best-effort — keep baked languages
    }
}

function pp_languages(): array
{
    pp_i18n_boot();
    return \$GLOBALS['PP_I18N']['languages'];
}

function pp_lang(): string
{
    \$codes = array_column(pp_languages(), 'code');
    // Session first (logged-in), then the persistent cookie (survives new sessions
    // AND public/anonymous pages that never start a session), then the default.
    \$lang = (string) (\$_SESSION['pp_lang'] ?? \$_COOKIE['pp_lang'] ?? '');
    return in_array(\$lang, \$codes, true) ? \$lang : \$GLOBALS['PP_I18N']['default'];
}

function pp_set_lang(string \$code): void
{
    if (in_array(\$code, array_column(pp_languages(), 'code'), true)) {
        \$_SESSION['pp_lang'] = \$code;
        // Persist for a year so the choice sticks across sessions / on public pages.
        if (!headers_sent()) {
            setcookie('pp_lang', \$code, [
                'expires'  => time() + 31536000,
                'path'     => '/',
                'samesite' => 'Lax',
            ]);
        }
        \$_COOKIE['pp_lang'] = \$code;   // reflect immediately for this request
    }
}

/**
 * Translate a key for the current language. Resolution order:
 *   1. the active language's value,
 *   2. the default (primary) language's value — so a customised English/primary
 *      label carries into languages that haven't translated that field yet,
 *   3. the caller's \$fallback (the designed label), else the key.
 */
function pp_t(string \$key, ?string \$fallback = null): string
{
    \$lang = pp_lang();
    \$def  = \$GLOBALS['PP_I18N']['default'] ?? 'en';
    // Live DB overrides win over the baked dictionary (active → default).
    if (pp_i18n_live() && class_exists('\\\\PpPhp\\\\Core\\\\I18n\\\\TranslationStore')) {
        try {
            \$ov = \PpPhp\Core\I18n\TranslationStore::overlay([\$lang, \$def]);
            \$v = \$ov[\$lang][\$key] ?? null;
            if ((\$v === null || \$v === '') && \$def !== \$lang) { \$v = \$ov[\$def][\$key] ?? null; }
            if (\$v !== null && \$v !== '') return \$v;
        } catch (\Throwable \$e) {
            // fall through to the baked dictionary
        }
    }
    \$dict = \$GLOBALS['PP_I18N']['dict'] ?? [];
    \$val = \$dict[\$lang][\$key] ?? null;
    if (\$val === null || \$val === '') {
        if (\$def !== \$lang) { \$val = \$dict[\$def][\$key] ?? null; }
    }
    return (\$val !== null && \$val !== '') ? \$val : (\$fallback ?? \$key);
}

function pp_table_caption(string \$table, string \$fallback): string
{
    return pp_t('table.' . \$table . '.caption', \$fallback);
}

function pp_page_title(string \$table, string \$type, string \$fallback): string
{
    return pp_t('page.' . \$table . '.' . \$type, \$fallback);
}

/**
 * Translate a Designer component CONTENT string — the headings, labels, titles
 * and button text the user authored on the canvas. Keyed by the authored text
 * itself under a `content.` namespace, so it rides the SAME active→default→
 * fallback resolution and the SAME live-editing overlay as every other
 * translatable surface. Untranslated strings return the authored text verbatim,
 * so this is always safe to wrap around component text.
 */
function pp_ct(string \$src): string
{
    if (trim(\$src) === '') return \$src;
    return pp_t('content.' . \$src, \$src);
}

/** HTML-escaped pp_ct(), for echoing straight into markup or an attribute. */
function pp_ct_e(string \$src): string
{
    return htmlspecialchars(pp_ct(\$src), ENT_QUOTES, 'UTF-8');
}
LANGPHP;

        $this->log('Languages: ' . implode(', ', array_column($languages, 'code')));
        $written = [$this->writeFile($appDir . '/lang.php', $content . "\n")];
        // The editable catalogue is only consumed by the live in-app editor.
        if (($this->ctx['miscProject']['liveTranslations'] ?? false) === true) {
            $written[] = $this->writeFile($appDir . '/i18n-catalogue.php', $catalogueContent);
        }
        return $written;
    }

    /**
     * Generate the application shell (app/shell.php + assets/css/shell.css):
     * a modern responsive topbar + sidebar driven by the Style designer —
     * brand from the theme shell config, menu from the Menu Editor.
     */
    /**
     * Compile the footer text into PHP source for the generated shell config.
     *
     * The footer is a PHP-enabled template: `{expr}` segments — e.g.
     * `© Your Company {date('Y')}` — become real PHP expressions evaluated at
     * runtime (raw `<?= expr ?>` / `<?php echo expr; ?>` typed by the user are
     * normalized to the same form). Literals are HTML-escaped at build time,
     * expression results at runtime; the whole thing is wrapped in try/catch
     * so a bad snippet degrades to the literal text instead of breaking pages.
     * Plain text (no snippet) stays a plain exported string.
     */
    private function compileFooterSource(string $text): string
    {
        $tpl = (string) preg_replace('/<\?=\s*(.*?)\s*\?>/s', '{$1}', $text);
        $tpl = (string) preg_replace('/<\?php\s*echo\s+(.*?);?\s*\?>/s', '{$1}', $tpl);
        if (!preg_match('/\{[^{}]+\}/', $tpl)) {
            return var_export($text, true);
        }

        $pieces = [];
        foreach (preg_split('/(\{[^{}]+\})/', $tpl, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [] as $part) {
            if (preg_match('/^\{([^{}]+)\}$/', $part, $m)) {
                $pieces[] = 'htmlspecialchars((string) (' . trim($m[1]) . '))';
            } else {
                $pieces[] = var_export(htmlspecialchars($part), true);
            }
        }
        $expr = implode("\n            . ", $pieces);
        $fallback = var_export(htmlspecialchars($text), true);

        return "static function (): string {\n"
            . "        try {\n"
            . "            return {$expr};\n"
            . "        } catch (\\Throwable \$e) {\n"
            . "            return {$fallback};\n"
            . "        }\n"
            . "    }";
    }

    public function generateShell(string $outputDir, array $tables): array
    {
        $this->log('Generating application shell (navbar + menu)…');
        $appDir = $outputDir . '/app';
        if (!is_dir($appDir)) mkdir($appDir, 0755, true);
        $cssDir = $outputDir . '/assets/css';
        if (!is_dir($cssDir)) mkdir($cssDir, 0755, true);

        $shellCfg = (array) ($this->ctx['shell'] ?? []);
        $brand = trim((string) ($shellCfg['logo']['text'] ?? '')) ?: (string) ($this->ctx['title'] ?? 'App');
        $brandImage = trim((string) ($shellCfg['logo']['image'] ?? ''));
        // Favicon (Style → Layout → Favicon). Falls back to the brand logo image.
        $favicon = trim((string) ($shellCfg['favicon'] ?? '')) ?: $brandImage;
        $footerText = (string) ($shellCfg['footer']['text'] ?? '');
        $footerOn = ($shellCfg['footer']['enabled'] ?? true) && $footerText !== '';
        $footerAlign = (string) ($shellCfg['footer']['align'] ?? 'left');
        if (!in_array($footerAlign, ['left', 'center', 'right'], true)) $footerAlign = 'left';

        // Full rendered menu (designed/default + auto-injected Calendar), shared
        // with the Translations "Menu" catalogue so both list identical items.
        $menuItems = $this->menuItemsResolved(array_keys($tables));
        $menuExport = var_export($menuItems, true);
        $brandExport = var_export($brand, true);
        $footerAlignExport = var_export($footerAlign, true);
        $footerExport = $footerOn ? $this->compileFooterSource($footerText) : var_export('', true);
        $brandImageExport = var_export($brandImage, true);
        $faviconExport = var_export($favicon, true);
        $notifExport = var_export($this->notifBellNeeded(), true);
        $darkExport = var_export(!empty($this->ctx['miscProject']['darkMode']), true);
        $gdprExport = var_export(!empty($this->ctx['miscProject']['gdprBanner']), true);

        // ── Navbar layout (Style → Layout) ───────────────────────────────────
        // The chosen navbar type decides the shell's ARRANGEMENT: whether there
        // is a sidebar at all, which side it sits on, and whether the menu runs
        // horizontally in the topbar. Previously the build ignored this entirely,
        // so picking "Horizontal" changed the Style preview but shipped Classic.
        $navbar = (string) ($shellCfg['navbar'] ?? 'classic');
        if (\PpPhp\Core\Theme\NavbarRegistry::get($navbar) === null) $navbar = 'classic';
        $navDef = \PpPhp\Core\Theme\NavbarRegistry::get($navbar) ?? [];

        // Explicit user settings win over the type's defaults.
        $placement = (string) ($shellCfg['menu']['placement'] ?? ($navDef['menuPlacement'] ?? 'sidebar'));
        $topOn     = ($shellCfg['topbar']['enabled']  ?? ($navDef['defaults']['topbar']['enabled']  ?? true)) !== false;
        $sideOn    = ($shellCfg['sidebar']['enabled'] ?? ($navDef['defaults']['sidebar']['enabled'] ?? true)) !== false;
        // A sidebar only renders when the menu actually lives there.
        $sideOn    = $sideOn && $placement === 'sidebar';
        $sidePos   = ($shellCfg['sidebar']['position'] ?? ($navDef['defaults']['sidebar']['position'] ?? 'left')) === 'right' ? 'right' : 'left';
        $railOn    = !empty($shellCfg['sidebar']['compact'] ?? ($navDef['defaults']['sidebar']['compact'] ?? false));
        // Boxed/hero read better unpinned, so the type's default applies
        // unless the user chose explicitly.
        $topFixed  = ($shellCfg['topbar']['fixed'] ?? ($navDef['defaults']['topbar']['fixed'] ?? true)) !== false;

        // Topbar content + finish. These were previously offered in the Style UI
        // but never read here, so the generated app ignored them.
        $topGrad   = !empty($shellCfg['topbar']['gradient']);
        $topSearch = ($shellCfg['topbar']['search'] ?? true) !== false;
        $topUser   = ($shellCfg['topbar']['user']   ?? true) !== false;
        // Shrink/blur only mean anything while the bar is pinned.
        $topShrink = !empty($shellCfg['topbar']['shrink']) && $topFixed;
        $topBlur   = !empty($shellCfg['topbar']['blur'])   && $topFixed;
        $topElev   = (string) ($shellCfg['topbar']['elevation'] ?? 'shadow');
        if (!in_array($topElev, ['none', 'shadow', 'border'], true)) $topElev = 'shadow';
        // Offered in the Style UI but, like the topbar flags, never read here.
        $sideFixed = ($shellCfg['sidebar']['fixed'] ?? ($navDef['defaults']['sidebar']['fixed'] ?? true)) !== false;
        // Auto-hide: shrink the sidebar to an icon rail when the viewport is too
        // narrow to give a wide page room. A width, not a guess about content —
        // predictable, and it reverses cleanly when the window grows again.
        $sideAuto  = !empty($shellCfg['sidebar']['autoHide']);
        $sideAutoAt = (string) ($shellCfg['sidebar']['autoHideBelow'] ?? '1280px');
        if (!preg_match('/^\d{3,4}px$/', $sideAutoAt)) $sideAutoAt = '1280px';

        $navbarExport    = var_export($navbar, true);
        $placementExport = var_export($placement, true);
        $topOnExport     = var_export($topOn, true);
        $sideOnExport    = var_export($sideOn, true);
        $sidePosExport   = var_export($sidePos, true);
        $railExport      = var_export($railOn, true);
        $topFixedExport  = var_export($topFixed, true);
        $topGradExport   = var_export($topGrad, true);
        $topSearchExport = var_export($topSearch, true);
        $topUserExport   = var_export($topUser, true);
        $topShrinkExport = var_export($topShrink, true);
        $topBlurExport   = var_export($topBlur, true);
        $topElevExport   = var_export($topElev, true);
        $sideFixedExport = var_export($sideFixed, true);
        $sideAutoExport   = var_export($sideAuto, true);
        $sideAutoAtExport = var_export((int) $sideAutoAt, true);

        $shellPhp = <<<SHELLPHP
<?php

declare(strict_types=1);

/**
 * Application shell — generated from the Style designer + Misc module.
 * Brand + footer come from the theme shell config; the menu from the Menu
 * Editor; language selector / notifications / dark mode / GDPR from Misc.
 */

\$GLOBALS['PP_SHELL'] = [
    'brand'  => {$brandExport},
    'brandImage' => {$brandImageExport},
    'favicon' => {$faviconExport},
    'footer' => {$footerExport},
    'footerAlign' => {$footerAlignExport},
    'notif'  => {$notifExport},
    'dark'   => {$darkExport},
    'gdpr'   => {$gdprExport},
    'menu'   => {$menuExport},
    // Navbar layout, so the shell renderer knows how to arrange itself.
    'navbar'    => {$navbarExport},
    'placement' => {$placementExport},
    'topbarOn'  => {$topOnExport},
    'sidebarOn' => {$sideOnExport},
    'sidebarPos'=> {$sidePosExport},
    'rail'      => {$railExport},
    'topFixed'  => {$topFixedExport},
    'topGrad'   => {$topGradExport},
    'topSearch' => {$topSearchExport},
    'topUser'   => {$topUserExport},
    'topShrink' => {$topShrinkExport},
    'topBlur'   => {$topBlurExport},
    'topElev'   => {$topElevExport},
    'sideFixed' => {$sideFixedExport},
    'sideAuto'  => {$sideAutoExport},
    'sideAutoAt'=> {$sideAutoAtExport},
];

/**
 * Universal icon markup — same conventions as the platform designer:
 * Bootstrap (`bi-x` / bare name), FontAwesome (`fa-solid fa-x` / `fa-x`),
 * Material (`material-symbols-outlined name` → ligature span).
 */
function pp_icon(string \$icon): string
{
    \$v = trim(\$icon);
    if (\$v === '') return '';
    if (str_starts_with(\$v, 'material-symbols')) {
        \$parts = preg_split('/\s+/', \$v);
        return '<span class="material-symbols-outlined">' . htmlspecialchars(end(\$parts)) . '</span>';
    }
    if (str_contains(\$v, ' ')) return '<i class="' . htmlspecialchars(\$v) . '"></i>';
    if (str_starts_with(\$v, 'bi-')) return '<i class="bi ' . htmlspecialchars(\$v) . '"></i>';
    if (str_starts_with(\$v, 'fa-')) return '<i class="fa-solid ' . htmlspecialchars(\$v) . '"></i>';
    return '<i class="bi bi-' . htmlspecialchars(\$v) . '"></i>';
}

function pp_menu_html(array \$items, string \$active, int \$depth = 0, string \$mode = 'side'): string
{
    \$h = '<ul class="pp-menu' . (\$depth > 0 ? ' pp-menu-sub' : '') . '">';
    foreach (\$items as \$item) {
        \$isActive = \$active !== '' && (\$item['key'] ?? '') === \$active;
        \$icon = pp_icon((string) (\$item['icon'] ?? ''));
        // Menu labels localise via the Misc → Label Editor → Menu tab (menu.<tkey>),
        // falling back to the designed label.
        \$rawLabel = (string) (\$item['label'] ?? '');
        \$mKey = (string) (\$item['tkey'] ?? '');
        \$label = htmlspecialchars((\$mKey !== '' && function_exists('pp_t')) ? pp_t('menu.' . \$mKey, \$rawLabel) : \$rawLabel);
        if (!empty(\$item['divider'])) {
            \$dstyle = (string) (\$item['dividerStyle'] ?? 'line');
            if (\$dstyle === 'label') {
                \$h .= '<li class="pp-menu-divider pp-menu-divider-label">' . \$label . '</li>';
            } else {
                \$h .= '<li class="pp-menu-divider pp-menu-divider-' . htmlspecialchars(\$dstyle) . '" aria-hidden="true"></li>';
            }
            continue;
        }
        if (!empty(\$item['children'])) {
            \$childHtml = pp_menu_html(\$item['children'], \$active, \$depth + 1, \$mode);
            // Top-level groups in the horizontal topbar are click/hover dropdowns
            // (a <button> + positioned submenu), NOT <details> accordions — those
            // only work stacked in a sidebar. Deeper groups stay as accordions.
            if (\$mode === 'top' && \$depth === 0) {
                \$hasActive = str_contains(\$childHtml, 'pp-menu-active');
                \$h .= '<li class="pp-menu-group">'
                    . '<button type="button" class="pp-menu-link pp-menu-toggle' . (\$hasActive ? ' pp-menu-active' : '') . '"'
                    . ' aria-haspopup="true" aria-expanded="false">' . \$icon . '<span>' . \$label
                    . '</span><i class="bi bi-chevron-down pp-menu-caret"></i></button>'
                    . \$childHtml . '</li>';
                continue;
            }
            \$open = str_contains(\$childHtml, 'pp-menu-active') ? ' open' : '';
            \$h .= '<li class="pp-menu-group"><details' . \$open . '><summary>' . \$icon
                . '<span>' . \$label . '</span><i class="bi bi-chevron-down pp-menu-caret"></i></summary>'
                . \$childHtml . '</details></li>';
            continue;
        }
        \$href = !empty(\$item['external'])
            ? (preg_match('#^(https?:)?//#i', (string) \$item['href']) ? (string) \$item['href'] : APP_BASE . (string) \$item['href'])
            : APP_BASE . (string) (\$item['href'] !== '' ? \$item['href'] : '/');
        \$target = !empty(\$item['blank']) ? ' target="_blank" rel="noopener"' : '';
        \$h .= '<li><a class="pp-menu-link' . (\$isActive ? ' pp-menu-active' : '') . '" href="'
            . htmlspecialchars(\$href) . '"' . \$target
            . (\$isActive ? ' aria-current="page"' : '') . '>'
            . \$icon . '<span>' . \$label . '</span></a></li>';
    }
    return \$h . '</ul>';
}

/**
 * Menu permissions — the Menu Editor can restrict any item or group to a set
 * of roles. An item with no roles is visible to everyone; otherwise it shows
 * only when the signed-in user's role is in the list (a signed-out visitor
 * sees only unrestricted items). A group whose children are all hidden is
 * dropped so no empty submenus render.
 */
function pp_menu_role_ok(array \$item, string \$role): bool
{
    // No database authentication → no roles to gate on; every item is visible.
    if (defined('PP_AUTH_ENABLED') && !PP_AUTH_ENABLED) return true;
    // Permission filtering: an item that opens a table page the user cannot
    // access at all is hidden, so the menu never shows a link that would only
    // land on the no-access page.
    if (class_exists('\\PpPhp\\Core\\Auth\\Permissions')) {
        // The shell menu is flattened: `key` is the table, `href` the URL,
        // `external` the flag. Gate only internal links to a governed data
        // table; leave headings, dividers, custom pages and external links.
        \$tbl = (string) (\$item['key'] ?? '');
        if (\$tbl === '' && empty(\$item['external'])) {
            // fall back to the table in an internal /generated/<table>/ href
            if (preg_match('#/generated/([A-Za-z0-9_]+)/#', (string) (\$item['href'] ?? ''), \$m)) {
                \$tbl = \$m[1];
            }
        }
        if (\$tbl !== '' && empty(\$item['external'])
            && \PpPhp\Core\Auth\Permissions::governs(\$tbl)
            && !\PpPhp\Core\Auth\Permissions::anyAccess(\$tbl)) {
            return false;
        }
    }
    \$roles = \$item['roles'] ?? [];
    if (!is_array(\$roles) || \$roles === []) return true;
    if (\$role === '') return false;
    return in_array(\$role, \$roles, true);
}

function pp_menu_filter(array \$items, string \$role): array
{
    \$out = [];
    foreach (\$items as \$item) {
        if (!is_array(\$item)) continue;
        if (!pp_menu_role_ok(\$item, \$role)) continue;
        \$isGroup = !empty(\$item['children']);
        if (\$isGroup) {
            \$item['children'] = pp_menu_filter((array) \$item['children'], \$role);
            if (\$item['children'] === []) continue;
        }
        // no leading or consecutive dividers left dangling by role filtering
        if (!empty(\$item['divider'])) {
            \$last = \$out === [] ? null : \$out[count(\$out) - 1];
            if (\$last === null || !empty(\$last['divider'])) continue;
        }
        \$out[] = \$item;
    }
    // no trailing divider
    while (\$out !== [] && !empty(\$out[count(\$out) - 1]['divider'])) array_pop(\$out);
    return \$out;
}

function pp_shell_start(string \$title, string \$active = '', string \$breadcrumb = ''): void
{
    \$cfg = \$GLOBALS['PP_SHELL'];
    \$user = \PpPhp\Core\Security::CurrentUserData() ?: [];   // anonymous on public pages (A8) → empty, warning-free
    // The account's own name is data, but route it through a per-name translation
    // key so it can follow the app language when a translation is provided (e.g. a
    // role-style name like "Administrator"); it falls back to the actual name.
    \$__nameRaw = trim((string) (\$user['fullname'] ?? \$user['username'] ?? ''));
    \$displayName = htmlspecialchars(\$__nameRaw !== ''
        ? pp_t('user.' . preg_replace('/[^a-z0-9]+/', '_', strtolower(\$__nameRaw)), \$__nameRaw)
        : pp_t('ui.user', 'User'));
    \$initial = strtoupper(substr((string) (\$user['username'] ?? 'U'), 0, 1));
    \$brand = htmlspecialchars((string) \$cfg['brand']);
    \$brandInitials = strtoupper(substr((string) \$cfg['brand'], 0, 2));
    // ── Theme switcher runtime (System/Light/Dark) — emitted before any shell
    //    markup so the saved preference paints without a flash. ──
    if (!empty(\$cfg['dark'])) {
        echo '<script>(function(){var K="pp_theme";'
            . 'function eff(c){return c==="dark"||(c==="system"&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches);}'
            . 'function saved(){try{var v=localStorage.getItem(K);if(v)return v;var o=localStorage.getItem("pp_dark");if(o==="1")return"dark";if(o==="0")return"light";}catch(e){}return"system";}'
            . 'window.ppSetTheme=function(c,persist){document.documentElement.classList.toggle("is-dark",eff(c));'
            . 'if(persist){try{localStorage.setItem(K,c);}catch(e){}}'
            . 'var box=document.querySelector(".pp-theme");if(box){'
            . 'box.querySelectorAll("[data-pp-theme-opt]").forEach(function(b){b.setAttribute("aria-checked",b.getAttribute("data-pp-theme-opt")===c?"true":"false");});'
            . 'var ic=box.querySelector(".pp-theme-ic");if(ic){ic.className="pp-theme-ic bi "+(c==="system"?"bi-circle-half":(c==="dark"?"bi-moon-stars-fill":"bi-sun-fill"));}}};'
            . 'ppSetTheme(saved(),false);'
            . 'if(window.matchMedia){var mq=window.matchMedia("(prefers-color-scheme: dark)");(mq.addEventListener?mq.addEventListener.bind(mq,"change"):mq.addListener.bind(mq))(function(){if(saved()==="system")ppSetTheme("system",false);});}'
            . 'document.addEventListener("DOMContentLoaded",function(){ppSetTheme(saved(),false);});'
            . '})();</script>';
    }
    // Layout classes drive the variant CSS: which bars exist, which side the
    // sidebar is on, and whether it is a compact icon rail.
    \$ppNav  = (string) (\$cfg['navbar'] ?? 'classic');
    \$ppRail = !empty(\$cfg['rail']);
    \$ppSideOn = (\$cfg['sidebarOn'] ?? true) !== false;
    \$ppTopOn  = (\$cfg['topbarOn'] ?? true) !== false;
    \$ppShellCls = 'pp-shell nb-' . preg_replace('/[^a-z0-9_-]/', '', \$ppNav)
        . (\$ppSideOn ? '' : ' no-sidebar') . (\$ppTopOn ? '' : ' no-topbar')
        // Menu in the topbar on desktop, but collapsing to the sidebar drawer on
        // mobile: this class drives that responsive swap in shellCss().
        . (((\$cfg['placement'] ?? 'sidebar') === 'topbar') ? ' menu-topbar' : '')
        . (\$ppRail ? ' has-rail' : '')
        . ((\$cfg['sidebarPos'] ?? 'left') === 'right' ? ' side-right' : '')
        . ((\$cfg['topFixed'] ?? true) === false ? ' topbar-static' : '')
        . ((\$cfg['sideFixed'] ?? true) === false ? ' sidebar-static' : '');
    // Favicon (Style → Layout → Favicon; falls back to the logo image). Emitted here
    // so EVERY shell page gets it from one place — a <link rel=icon> placed at body
    // start is hoisted into <head> by browsers.
    if (!empty(\$cfg['favicon'])) {
        \$__ppFav = (string) \$cfg['favicon'];
        \$__ppFavUrl = (str_starts_with(\$__ppFav, 'http') || str_starts_with(\$__ppFav, '//') || str_starts_with(\$__ppFav, 'data:'))
            ? \$__ppFav : APP_BASE . \$__ppFav;
        \$__ppFavUrl = htmlspecialchars(\$__ppFavUrl, ENT_QUOTES);
        echo '<link rel="icon" href="' . \$__ppFavUrl . '"><link rel="apple-touch-icon" href="' . \$__ppFavUrl . '">';
    }
    echo '<div class="' . \$ppShellCls . '">';
    // First focusable element on the page: lets keyboard and screen-reader users
    // jump past the whole navigation instead of tabbing through every menu item.
    echo '<a class="pp-skip" href="#ppMain">' . pp_t('ui.skip_to_content', 'Skip to content') . '</a>';
    \$ppTbCls = 'pp-topbar'
        . (!empty(\$cfg['topGrad'])   ? ' has-grad'   : '')
        . (!empty(\$cfg['topBlur'])   ? ' has-blur'   : '')
        . (!empty(\$cfg['topShrink']) ? ' can-shrink' : '')
        . ' elev-' . preg_replace('/[^a-z]/', '', (string) (\$cfg['topElev'] ?? 'shadow'));
    if (\$ppTopOn) echo '<header class="' . \$ppTbCls . '">'
        . '<button class="pp-burger" type="button" id="ppBurger"'
        . ' aria-label="' . htmlspecialchars(pp_t('ui.toggle_menu', 'Toggle menu')) . '"'
        . ' aria-controls="ppSidebar" aria-expanded="true" '
        . 'onclick="var b=document.body,d=window.matchMedia(\\'(min-width: 992px)\\').matches;'
        . 'b.classList.toggle(d?\\'pp-nav-collapsed\\':\\'pp-nav-open\\');'
        . 'if(d){try{localStorage.setItem(\\'pp_nav\\',b.classList.contains(\\'pp-nav-collapsed\\')?\\'0\\':\\'1\\')}catch(e){}}">'
        . '<i class="bi bi-layout-sidebar"></i></button>'
        . '<a class="pp-brand" href="' . htmlspecialchars(APP_BASE . '/') . '">'
        . ((\$cfg['brandImage'] ?? '') !== ''
            ? '<img class="pp-brand-img" src="' . htmlspecialchars(str_starts_with((string) \$cfg['brandImage'], 'http') ? (string) \$cfg['brandImage'] : APP_BASE . (string) \$cfg['brandImage']) . '" alt="' . \$brand . '">'
            : '<span class="pp-brand-mark">' . htmlspecialchars(\$brandInitials) . '</span>')
        . '<span class="pp-brand-name">' . \$brand . '</span></a>'
        . ((\$cfg['placement'] ?? 'sidebar') === 'topbar'
            ? '<nav class="pp-topnav">' . pp_menu_html(pp_menu_filter(\$cfg['menu'], (string) (\$user['role'] ?? '')), \$active, 0, 'top') . '</nav>'
            : '')
        . '<div class="pp-topbar-spacer"></div>';

    // ── Quick find (Style → Topbar → Show search) ──
    // A client-side jump list over the menu the user can actually reach; no
    // backend call, so it works on every page and stays instant.
    if (!empty(\$cfg['topSearch'])) {
        \$ppNavItems = [];
        \$walk = static function (array \$items) use (&\$walk, &\$ppNavItems): void {
            foreach (\$items as \$it) {
                \$label = trim((string) (\$it['label'] ?? ''));
                \$href  = (string) (\$it['href'] ?? '');
                // Same href rules as pp_menu_html: external links pass through,
                // internal ones are APP_BASE-relative. Dividers have no target.
                if (\$label !== '' && empty(\$it['divider'])) {
                    \$ppNavItems[] = [
                        'l' => \$label,
                        'u' => !empty(\$it['external']) ? \$href : APP_BASE . (\$href !== '' ? \$href : '/'),
                    ];
                }
                if (!empty(\$it['children']) && is_array(\$it['children'])) \$walk(\$it['children']);
            }
        };
        \$walk(pp_menu_filter(\$cfg['menu'], (string) (\$user['role'] ?? '')));
        echo '<div class="pp-tb-search">'
            . '<i class="bi bi-search"></i>'
            . '<input type="search" id="ppQuickFind" autocomplete="off" role="combobox" aria-expanded="false"'
            . ' aria-controls="ppQuickList" placeholder="' . htmlspecialchars(pp_t('ui.search', 'Search…')) . '">'
            . '<kbd class="pp-tb-kbd">/</kbd>'
            . '<div class="pp-quick" id="ppQuickList" role="listbox" hidden></div>'
            . '</div>'
            . '<script>window.PP_NAV=' . json_encode(\$ppNavItems, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';</script>';
    }

    // ── Language selector (Misc → multiple languages) ──
    // Shown only when 2+ languages exist AND the owner hasn't hidden it
    // (Misc → Project → "Show language selector").
    if (function_exists('pp_languages') && count(pp_languages()) > 1 && (\$GLOBALS['PP_I18N']['selector'] ?? true)) {
        \$current = pp_lang();
        \$currentName = \$current;
        \$currentFlag = '';
        foreach (pp_languages() as \$l) {
            if ((\$l['code'] ?? '') === \$current) {
                \$currentName = (string) (\$l['name'] ?? \$current);
                \$currentFlag = (string) (\$l['flag'] ?? '');
                break;
            }
        }
        echo '<details class="pp-lang"><summary>'
            . (\$currentFlag !== '' ? '<span class="pp-flag">' . \$currentFlag . '</span>' : '<i class="bi bi-globe2"></i>')
            . '<span class="pp-lang-name">' . htmlspecialchars(\$currentName) . '</span><i class="bi bi-chevron-down"></i></summary>'
            . '<div class="pp-user-menu">';
        \$qs = \$_GET;
        foreach (pp_languages() as \$l) {
            \$qs['pp_lang'] = (string) \$l['code'];
            \$href = strtok((string) (\$_SERVER['REQUEST_URI'] ?? '/'), '?') . '?' . http_build_query(\$qs);
            \$mark = ((\$l['code'] ?? '') === \$current) ? ' <i class="bi bi-check2"></i>' : '';
            \$flag = (string) (\$l['flag'] ?? '');
            echo '<a href="' . htmlspecialchars(\$href) . '">'
                . (\$flag !== '' ? '<span class="pp-flag">' . \$flag . '</span> ' : '')
                . htmlspecialchars((string) (\$l['name'] ?? \$l['code'])) . \$mark . '</a>';
        }
        echo '</div></details>';
    }

    // ── Notifications (Misc → Advanced) ──
    if (!empty(\$cfg['notif'])) {
        // Mirrors the console's notification bell (core/views/notification-bell.php):
        // titled header with a "Mark all read" action, per-item icon + title +
        // relative time, and a friendly empty state.
        echo '<details class="pp-notif"><summary aria-label="' . htmlspecialchars(pp_t('ui.notifications', 'Notifications')) . '"><i class="bi bi-bell"></i>'
            . '<span class="pp-notif-badge" id="ppNotifBadge" style="display:none">0</span></summary>'
            . '<div class="pp-user-menu pp-notif-menu">'
            . '<div class="pp-notif-head"><strong>' . pp_t('ui.notifications', 'Notifications') . '</strong>'
            . '<button type="button" class="pp-notif-clear" id="ppNotifClear">' . pp_t('ui.mark_all_read', 'Mark all read') . '</button></div>'
            . '<div id="ppNotifList" class="pp-notif-list">'
            . '<div class="pp-notif-empty"><i class="bi bi-check2-circle"></i><span>'
            . pp_t('ui.all_caught_up', "You're all caught up.") . '</span></div></div>'
            . '</div></details>';
    }

    // ── Theme switcher: System / Light / Dark (Misc → Project) ──
    if (!empty(\$cfg['dark'])) {
        echo '<details class="pp-theme"><summary aria-haspopup="menu" aria-label="' . pp_t('ui.theme', 'Theme') . '">'
            . '<i class="pp-theme-ic bi bi-circle-half"></i><i class="bi bi-chevron-down pp-theme-caret"></i></summary>'
            . '<div class="pp-user-menu pp-theme-menu" role="menu">'
            . '<button type="button" class="pp-theme-opt" data-pp-theme-opt="system" role="menuitemradio" aria-checked="false" onclick="ppSetTheme(\\'system\\',true);this.closest(\\'details\\').open=false"><i class="bi bi-circle-half"></i><span>' . pp_t('ui.theme_system', 'System') . '</span><i class="bi bi-check2 pp-theme-ck"></i></button>'
            . '<button type="button" class="pp-theme-opt" data-pp-theme-opt="light" role="menuitemradio" aria-checked="false" onclick="ppSetTheme(\\'light\\',true);this.closest(\\'details\\').open=false"><i class="bi bi-sun-fill"></i><span>' . pp_t('ui.theme_light', 'Light') . '</span><i class="bi bi-check2 pp-theme-ck"></i></button>'
            . '<button type="button" class="pp-theme-opt" data-pp-theme-opt="dark" role="menuitemradio" aria-checked="false" onclick="ppSetTheme(\\'dark\\',true);this.closest(\\'details\\').open=false"><i class="bi bi-moon-stars-fill"></i><span>' . pp_t('ui.theme_dark', 'Dark') . '</span><i class="bi bi-check2 pp-theme-ck"></i></button>'
            . '</div></details>';
    }

    // The account menu (Profile / Sign out) is only meaningful when database
    // authentication is enabled. A no-auth build has no signed-in user, so this
    // chrome is suppressed entirely.
    // Needs auth (there is no user without it) AND the Style toggle.
    if (defined('PP_AUTH_ENABLED') && PP_AUTH_ENABLED && !empty(\$cfg['topUser'])) {
        echo '<details class="pp-user"><summary><span class="pp-avatar">' . htmlspecialchars(\$initial) . '</span>'
            . '<span class="pp-user-name">' . \$displayName . '</span><i class="bi bi-chevron-down"></i></summary>'
            . '<div class="pp-user-menu">'
            . '<a href="' . htmlspecialchars(APP_BASE . '/generated/auth/profile.php') . '"><i class="bi bi-person"></i> ' . pp_t('ui.profile', 'Profile') . '</a>'
            // Admin Area: only when permissions are managed live (dynamic mode)
            // AND the current user is an administrator. Nothing to manage in-app
            // for static/off, and non-admins must never see the link.
            // When already inside the Admin Area the same slot flips to a
            // clear "Exit Admin Area" that returns to the app home.
            . ((class_exists('\\PpPhp\\Core\\Auth\\Permissions')
                && \PpPhp\Core\Auth\Permissions::mode() === 'dynamic'
                && \PpPhp\Core\Auth\Permissions::isAdmin())
                ? (strpos((string) (\$_SERVER['SCRIPT_NAME'] ?? ''), '/generated/admin/') !== false
                    ? '<a href="' . htmlspecialchars(APP_BASE . '/') . '"><i class="bi bi-box-arrow-left"></i> ' . pp_t('ui.exit_admin_area', 'Exit Admin Area') . '</a>'
                    : '<a href="' . htmlspecialchars(APP_BASE . '/generated/admin/index.php') . '"><i class="bi bi-shield-lock"></i> ' . pp_t('ui.admin_area', 'Admin Area') . '</a>')
                : '')
            // Audit Log & Record Locks: admin-only, and only when that screen is set
            // to Dynamic (Misc → Advanced) so the app hosts its own page. Same slot,
            // same gating pattern as the Admin Area above.
            . ((defined('PP_AUDIT_ENABLED') && PP_AUDIT_ENABLED && defined('PP_AUDIT_MODE') && PP_AUDIT_MODE === 'dynamic'
                && class_exists('\\PpPhp\\Core\\Auth\\Permissions') && \PpPhp\Core\Auth\Permissions::isAdmin())
                ? '<a href="' . htmlspecialchars(APP_BASE . '/generated/admin/audit.php') . '"><i class="bi bi-journal-text"></i> ' . pp_t('ui.audit_log', 'Audit Log') . '</a>'
                : '')
            . ((defined('PP_LOCK_ENABLED') && PP_LOCK_ENABLED && defined('PP_LOCK_MODE') && PP_LOCK_MODE === 'dynamic'
                && class_exists('\\PpPhp\\Core\\Auth\\Permissions') && \PpPhp\Core\Auth\Permissions::isAdmin())
                ? '<a href="' . htmlspecialchars(APP_BASE . '/generated/admin/locks.php') . '"><i class="bi bi-lock-fill"></i> ' . pp_t('ui.record_locks', 'Record Locks') . '</a>'
                : '')
            // Live Translations editor: admin-only, only when the project enabled
            // live (DB) translation editing. Same slot + gating pattern.
            . ((function_exists('pp_i18n_live') && pp_i18n_live()
                && class_exists('\\PpPhp\\Core\\Auth\\Permissions') && \PpPhp\Core\Auth\Permissions::isAdmin())
                ? '<a href="' . htmlspecialchars(APP_BASE . '/generated/admin/translations.php') . '"><i class="bi bi-translate"></i> ' . pp_t('ui.translations', 'Translations') . '</a>'
                : '')
            // Two-Factor Authentication: user self-service (setup / manage),
            // shown only when the build offers 2FA at all.
            . ((defined('PP_2FA_ENABLED') && PP_2FA_ENABLED)
                ? '<a href="' . htmlspecialchars(APP_BASE . '/generated/auth/two-factor.php') . '"><i class="bi bi-shield-check"></i> ' . pp_t('ui.two_factor', 'Two-Factor Authentication') . '</a>'
                : '')
            . '<a href="' . htmlspecialchars(APP_BASE . '/generated/auth/logout.php') . '"><i class="bi bi-box-arrow-right"></i> ' . pp_t('ui.sign_out', 'Sign out') . '</a>'
            . '</div></details>';
    }
    echo '</header>';

    // ── Topbar horizontal menu: animated dropdowns (click + hover), close on
    //    outside-click/Escape, edge-flip, and a burger-toggled stacked panel on
    //    mobile. Only emitted when the menu actually lives in the topbar. ──
    if ((\$cfg['placement'] ?? 'sidebar') === 'topbar') {
        echo '<script>(function(){'
            . 'var tn=document.querySelector(".pp-topnav");if(!tn)return;'
            // On mobile the topbar menu is hidden and the sidebar drawer takes
            // over (the burger opens it via the shared nav handler), so no
            // topbar-specific mobile toggle is needed here.
            . 'var groups=tn.querySelectorAll("li.pp-menu-group");'
            . 'function closeAll(ex){groups.forEach(function(g){if(g!==ex){g.classList.remove("pp-open");var b=g.querySelector(".pp-menu-toggle");if(b)b.setAttribute("aria-expanded","false");}});}'
            . 'groups.forEach(function(g){var btn=g.querySelector(".pp-menu-toggle");if(!btn||btn.parentElement!==g)return;'
            . 'btn.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();'
            . 'var open=!g.classList.contains("pp-open");closeAll(g);g.classList.toggle("pp-open",open);btn.setAttribute("aria-expanded",open?"true":"false");g.classList.remove("pp-flip");'
            . 'if(open&&!window.matchMedia("(max-width:860px)").matches){var s=g.querySelector("ul.pp-menu-sub");if(s){var r=s.getBoundingClientRect();if(r.right>window.innerWidth-8)g.classList.add("pp-flip");}}});});'
            . 'document.addEventListener("click",function(e){if(!tn.contains(e.target))closeAll(null);});'
            . 'document.addEventListener("keydown",function(e){if(e.key==="Escape")closeAll(null);});'
            . '})();</script>';
    }

    // ── Topbar behaviour: compact-on-scroll + quick find ──
    if (\$ppTopOn && (!empty(\$cfg['topShrink']) || !empty(\$cfg['topSearch']))) {
        echo '<script>(function(){'
            . 'var tb=document.querySelector(".pp-topbar");if(!tb)return;'
            // compact-on-scroll, rAF-throttled so it costs nothing while scrolling
            . 'if(tb.classList.contains("can-shrink")){var t=false;'
            . 'var up=function(){tb.classList.toggle("is-shrunk",(window.scrollY||0)>28);t=false};'
            . 'addEventListener("scroll",function(){if(!t){t=true;requestAnimationFrame(up)}},{passive:true});up()}'
            // quick find over the nav items this user can actually reach
            . 'var i=document.getElementById("ppQuickFind"),l=document.getElementById("ppQuickList");'
            . 'if(!i||!l||!window.PP_NAV)return;var cur=-1;'
            . 'var close=function(){l.hidden=true;i.setAttribute("aria-expanded","false");cur=-1};'
            . 'var rows=function(){return l.querySelectorAll("a")};'
            . 'var mark=function(){rows().forEach(function(a,n){a.classList.toggle("is-on",n===cur);'
            . 'if(n===cur)a.scrollIntoView({block:"nearest"})})};'
            . 'var draw=function(){var q=i.value.trim().toLowerCase();'
            . 'if(!q){close();return}'
            . 'var hits=PP_NAV.filter(function(x){return x.l.toLowerCase().indexOf(q)!==-1}).slice(0,8);'
            . 'l.innerHTML=hits.length?hits.map(function(x){return \'<a href="\'+x.u+\'"><i class="bi bi-arrow-return-right"></i>\'+'
            . 'x.l.replace(/[&<>"]/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;"}[c]})+\'</a>\'}).join("")'
            . ':\'<div class="pp-quick-empty">No match</div>\';'
            . 'l.hidden=false;i.setAttribute("aria-expanded","true");cur=-1};'
            . 'i.addEventListener("input",draw);i.addEventListener("focus",draw);'
            . 'i.addEventListener("keydown",function(e){var r=rows();'
            . 'if(e.key==="ArrowDown"){e.preventDefault();cur=Math.min(cur+1,r.length-1);mark()}'
            . 'else if(e.key==="ArrowUp"){e.preventDefault();cur=Math.max(cur-1,0);mark()}'
            . 'else if(e.key==="Enter"){if(r[cur]){e.preventDefault();location.href=r[cur].href}}'
            . 'else if(e.key==="Escape"){close();i.blur()}});'
            . 'document.addEventListener("click",function(e){if(!e.target.closest(".pp-tb-search"))close()});'
            // "/" focuses the field, the way most apps do it
            . 'document.addEventListener("keydown",function(e){'
            . 'if(e.key==="/"&&!/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)&&!document.activeElement.isContentEditable){'
            . 'e.preventDefault();i.focus()}});'
            . '})();</script>';
    }

    // ── Topbar dropdowns: close on outside click / Escape ──
    // They are native <details>, which stay open until their own summary is
    // clicked again — so opening one left it hanging and two could be open at
    // once. This closes any open <details> in the topbar when focus goes
    // elsewhere, and keeps only one open at a time.
    echo '<script>(function(){'
        . 'var sel=".pp-topbar details";'
        . 'var all=function(){return document.querySelectorAll(sel)};'
        . 'document.addEventListener("click",function(e){'
        . 'var inside=e.target.closest(sel);'
        . 'all().forEach(function(d){ if(d!==inside) d.open=false; });'
        . '});'
        . 'document.addEventListener("keydown",function(e){'
        . 'if(e.key==="Escape"){all().forEach(function(d){'
        . 'if(d.open){d.open=false;var s=d.querySelector("summary");if(s)s.focus();}})}'
        . '});'
        // a link inside a menu should close it on the way out
        . 'document.addEventListener("click",function(e){'
        . 'var a=e.target.closest(sel+" a[href]");'
        . 'if(a){var d=a.closest("details"); if(d) d.open=false;}'
        . '});'
        . '})();</script>';

    echo '<div class="pp-nav-overlay" onclick="document.body.classList.remove(\\'pp-nav-open\\')"></div>';

    // ── Navigation behaviour: drawer dismissal, focus handling, shortcuts ──
    // The burger already toggles the classes inline; this keeps the accessible
    // state honest and makes the mobile drawer behave like a real dialog.
    echo '<script>(function(){'
        . 'var b=document.body,burger=document.getElementById("ppBurger");'
        // Resolve the sidebar lazily: this script is emitted BEFORE the <aside>,
        // so capturing it here would capture null and silently kill the focus
        // trap, the focus move and tap-to-close.
        . 'var sbEl=function(){return document.getElementById("ppSidebar")};'
        . 'var isMobile=function(){return !window.matchMedia("(min-width: 992px)").matches};'
        . 'var openNow=function(){return isMobile()?b.classList.contains("pp-nav-open"):!b.classList.contains("pp-nav-collapsed")};'
        . 'var sync=function(){if(burger)burger.setAttribute("aria-expanded",openNow()?"true":"false");'
        . 'b.classList.toggle("pp-nav-locked",isMobile()&&b.classList.contains("pp-nav-open"))};'
        . 'var close=function(back){if(!b.classList.contains("pp-nav-open"))return;'
        . 'b.classList.remove("pp-nav-open");sync();if(back&&burger)burger.focus()};'
        // keep state in sync however the classes were changed
        . 'new MutationObserver(sync).observe(b,{attributes:true,attributeFilter:["class"]});'
        . 'addEventListener("resize",sync);sync();'
        // Esc dismisses the drawer and returns focus to the control that opened it
        . 'document.addEventListener("keydown",function(e){'
        . 'if(e.key==="Escape"&&b.classList.contains("pp-nav-open")){e.preventDefault();close(true);return}'
        // "[" toggles the sidebar, the way editors do it
        . 'if(e.key==="["&&!/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)'
        . '&&!document.activeElement.isContentEditable&&burger){e.preventDefault();burger.click()}});'
        // a drawer over a scrim is modal: keep Tab inside it
        . 'document.addEventListener("keydown",function(e){'
        . 'if(e.key!=="Tab"||!isMobile()||!b.classList.contains("pp-nav-open"))return;'
        . 'var sb=sbEl();if(!sb)return;'
        . 'var f=sb.querySelectorAll(\'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])\');'
        . 'if(!f.length)return;var first=f[0],last=f[f.length-1];'
        . 'if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}'
        . 'else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}});'
        // opening the drawer moves focus into it
        . 'if(burger)burger.addEventListener("click",function(){setTimeout(function(){'
        . 'var sb=sbEl();'
        . 'if(isMobile()&&b.classList.contains("pp-nav-open")&&sb){var a=sb.querySelector("a[href]");if(a)a.focus()}},60)});'
        // a link tap inside the drawer should close it
        . 'document.addEventListener("click",function(e){'
        . 'if(isMobile()&&e.target.closest("#ppSidebar a[href]"))close(false)});'

        // ── Auto hide: shrink to the icon rail when the window is too narrow ──
        // Deliberately does NOT write localStorage: this is a response to the
        // window, not a preference, so it must never overwrite the user's own
        // collapsed/expanded choice.
        . 'var AUTO=' . (!empty(\$cfg['sideAuto']) ? 'true' : 'false') . ',AT=' . (int) (\$cfg['sideAutoAt'] ?? 1280) . ';'
        // matchMedia rather than a resize handler: it fires exactly on the
        // threshold crossing, costs nothing in between, and does not depend on
        // resize events being emitted (they are not, for some viewport changes).
        . 'if(AUTO){var mqA=matchMedia("(max-width: "+(AT-1)+"px)"),mqD=matchMedia("(min-width: 992px)");'
        . 'var auto=function(){b.classList.toggle("pp-nav-mini",mqA.matches&&mqD.matches)};'
        . '(mqA.addEventListener?mqA.addEventListener("change",auto):mqA.addListener(auto));'
        . '(mqD.addEventListener?mqD.addEventListener("change",auto):mqD.addListener(auto));'
        // Belt and braces: some environments deliver one of these events and not
        // the other. auto() is idempotent, so listening to both is free.
        . 'addEventListener("resize",auto);addEventListener("orientationchange",auto);'
        . 'auto()}'

        // ── Reveal the active item in a long menu, and fade the scroll edges ──
        . 'var nav=function(){var el=sbEl();return el&&el.querySelector(".pp-sidebar-nav")};'
        . 'var shade=function(){var el=sbEl();if(!el)return;'
        . 'var over=el.scrollHeight-el.clientHeight>4;'
        . 'el.classList.toggle("has-overflow-top",over&&el.scrollTop>4);'
        . 'el.classList.toggle("has-overflow-bottom",over&&el.scrollTop<el.scrollHeight-el.clientHeight-4)};'
        . 'var el0=sbEl();if(el0){el0.addEventListener("scroll",shade,{passive:true});'
        . 'addEventListener("resize",shade);'
        // a long menu should open showing where you are, not at the top
        . 'var act=el0.querySelector(".pp-menu-active");'
        . 'if(act&&el0.scrollHeight>el0.clientHeight+4)act.scrollIntoView({block:"nearest"});'
        . 'shade()}'
        . '})();</script>';
    // Events → global.app.menuModify: reorder / hide / add nav items before render.
    \PpPhp\Core\Events::fire('global.app.menuModify', ['items' => &\$cfg['menu'], 'user' => \$user]);
    \$ppMenu = pp_menu_filter(\$cfg['menu'], (string) (\$user['role'] ?? ''));
    \$ppFoot = (defined('PP_AUTH_ENABLED') && PP_AUTH_ENABLED)
        ? '<div class="pp-sidebar-foot"><a href="' . htmlspecialchars(APP_BASE . '/generated/auth/logout.php') . '" class="pp-menu-link">'
            . '<i class="bi bi-box-arrow-right"></i><span>' . pp_t('ui.logout', 'Logout') . '</span></a></div>'
        : '';
    // Render the sidebar whenever the menu lives there OR the menu lives in the
    // topbar — because on mobile the topbar menu is ALWAYS replaced by the
    // sidebar drawer (a cramped horizontal menu never shows on small screens).
    // On desktop the .menu-topbar rules hide this drawer and show the topnav.
    \$ppMenuTop = (\$cfg['placement'] ?? 'sidebar') === 'topbar';
    if (\$ppSideOn || (\$ppMenuTop && !empty(\$ppMenu))) {
        echo '<aside class="pp-sidebar" id="ppSidebar">'
            . '<nav class="pp-sidebar-nav" aria-label="' . htmlspecialchars(pp_t('ui.main_nav', 'Main navigation')) . '">'
            . pp_menu_html(\$ppMenu, \$active) . '</nav>'
            . \$ppFoot . '</aside>';
    }
    echo '<main class="pp-main" id="ppMain" tabindex="-1"><div class="pp-content">';
    // An empty title suppresses the page header entirely. The landing page
    // supplies its own hero, and a generic "Dashboard" heading above it is
    // just noise.
    if (trim(\$title) !== '' || \$breadcrumb !== '') {
        echo '<header class="pp-page-head">'
            . (trim(\$title) !== '' ? '<h1>' . htmlspecialchars(\$title) . '</h1>' : '')
            . (\$breadcrumb !== '' ? \$breadcrumb : '')
            . '</header>';
    }
}

function pp_shell_end(): void
{
    \$cfg = \$GLOBALS['PP_SHELL'];
    echo '</div>';
    if (\$cfg['footer'] !== '') {
        // A closure means the footer was compiled from a PHP-enabled template
        // ({date('Y')} snippets) and already returns HTML-safe output.
        \$footHtml = \$cfg['footer'] instanceof \Closure
            ? (\$cfg['footer'])()
            : htmlspecialchars((string) \$cfg['footer']);
        if (\$footHtml !== '') {
            \$fAlign = in_array(\$cfg['footerAlign'] ?? 'left', ['left', 'center', 'right'], true) ? \$cfg['footerAlign'] : 'left';
            echo '<footer class="pp-footer pp-footer-' . \$fAlign . '">' . \$footHtml . '</footer>';
        }
    }
    echo '</main></div>';

    // GDPR cookie-consent banner (Misc → Project; text from the Label Editor)
    if (!empty(\$cfg['gdpr'])) {
        echo '<div class="pp-gdpr" id="ppGdpr" style="display:none">'
            . '<span>' . htmlspecialchars(pp_t('cookie.banner', 'This application uses cookies to keep you signed in and remember your preferences.')) . '</span>'
            . '<button type="button" onclick="try{localStorage.setItem(\\'pp_gdpr\\',\\'1\\')}catch(e){};document.getElementById(\\'ppGdpr\\').style.display=\\'none\\'">OK</button>'
            . '</div>'
            . '<script>try{if(!localStorage.getItem(\\'pp_gdpr\\'))document.getElementById(\\'ppGdpr\\').style.display=\\'flex\\'}catch(e){}</script>';
    }

    // (Theme preference is applied up-front in pp_shell_start — no flash.)

    // Sidebar collapse persistence (desktop)
    echo '<script>try{if(localStorage.getItem(\\'pp_nav\\')===\\'0\\'&&window.matchMedia(\\'(min-width: 992px)\\').matches)document.body.classList.add(\\'pp-nav-collapsed\\')}catch(e){}</script>';

    // Notification polling
    if (!empty(\$cfg['notif'])) {
        // Same shape as the console bell: icon chip, title, message, relative
        // time, unread tint — and a Mark-all-read action.
        echo '<script>(function(){var u="' . htmlspecialchars(APP_BASE) . '/api/notifications.php";'
            . 'var esc=function(v){return String(v==null?"":v).replace(/[&<>"]/g,function(c){'
            . 'return ({"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;"})[c]})};'
            . 'var ago=function(t){var ts=typeof t==="number"?t:Math.floor(new Date(String(t).replace(" ","T")).getTime()/1000);'
            . 'if(!ts||isNaN(ts))return "";var s=Math.floor(Date.now()/1000)-ts;'
            . 'if(s<60)return ' . json_encode(pp_t('ui.time_just_now', 'Just now')) . ';if(s<3600)return Math.floor(s/60)+' . json_encode(' ' . pp_t('ui.time_min_ago', 'min ago')) . ';'
            . 'if(s<86400)return Math.floor(s/3600)+' . json_encode(' ' . pp_t('ui.time_hr_ago', 'hr ago')) . ';if(s<172800)return ' . json_encode(pp_t('ui.time_yesterday', 'Yesterday')) . ';'
            . 'if(s<2592000)return Math.floor(s/86400)+' . json_encode(' ' . pp_t('ui.time_days_ago', 'days ago')) . ';'
            . 'return new Date(ts*1000).toLocaleDateString()};'
            . 'var list=document.getElementById("ppNotifList"),badge=document.getElementById("ppNotifBadge");'
            . 'var wrap=badge?badge.closest(".pp-notif"):null,prev=0,first=true;'
            . 'var empty=\'<div class="pp-notif-empty"><i class="bi bi-check2-circle"></i><span>\'+'
            . json_encode(pp_t('ui.all_caught_up', "You're all caught up.")) . '+\'</span></div>\';'
            . 'var paint=function(items){if(!list)return;'
            . 'if(!items||!items.length){list.innerHTML=empty;return;}'
            . 'var tIcon=function(n){return n.icon||({info:"info-circle",success:"check-circle",warning:"exclamation-triangle",error:"x-circle"}[n.type]||"bell")};'
            . 'list.innerHTML=items.map(function(n){'
            . 'var tag=n.link?"a":"div",href=n.link?\' href="\'+esc(n.link)+\'"\':"";'
            . 'return "<"+tag+\' class="pp-notif-item pp-nt-\'+esc(n.type||"info")+(n.is_read?"":" unread")+\'"\'+href+">"'
            . '+\'<span class="pp-notif-ic"><i class="bi bi-\'+esc(tIcon(n))+\'"></i></span>\''
            . '+\'<span class="pp-notif-body">\''
            . '+(n.title?\'<span class="pp-notif-title">\'+esc(n.title)+"</span>":"")'
            . '+\'<span class="pp-notif-text">\'+esc(n.message)+"</span>"'
            . '+\'<span class="pp-notif-time">\'+esc(ago(n.ts||n.created_at))+"</span>"'
            . '+"</span></"+tag+">"}).join("")};'
            . 'var setBadge=function(n){if(!badge)return;'
            . 'badge.textContent=n>99?"99+":n;badge.style.display=n>0?"inline-flex":"none";'
            . 'if(wrap)wrap.classList.toggle("has-unread",n>0);'
            // A new message arrived (count rose, and not the very first load) →
            // pop the badge + swing the bell once.
            . 'if(n>prev&&!first){badge.classList.remove("pop");void badge.offsetWidth;badge.classList.add("pop");'
            . 'if(wrap){wrap.classList.remove("ring");void wrap.offsetWidth;wrap.classList.add("ring");'
            . 'setTimeout(function(){if(wrap)wrap.classList.remove("ring");},1100);}}'
            . 'prev=n;first=false};'
            // A slide-in toast for the newest unread notification when one arrives.
            . 'var toast=function(n){var t=document.createElement("div");t.className="pp-notif-toast pp-nt-"+(n.type||"info");'
            . 't.innerHTML=\'<span class="pp-notif-toast-ic"><i class="bi bi-\'+esc(tIcon(n))+\'"></i></span>\''
            . '+\'<span class="pp-notif-toast-b">\'+(n.title?"<b>"+esc(n.title)+"</b>":"")+"<span>"+esc(n.message)+"</span></span>";'
            . 'if(n.link){t.style.cursor="pointer";t.addEventListener("click",function(){location.href=n.link});}'
            . 'document.body.appendChild(t);requestAnimationFrame(function(){t.classList.add("show")});'
            . 'setTimeout(function(){t.classList.remove("show");setTimeout(function(){t.remove()},320)},5200)};'
            . 'var lastTop=null,booted=false;'
            . 'var load=function(){fetch(u,{credentials:"same-origin"}).then(function(r){return r.json()})'
            . '.then(function(j){if(!j||!j.success)return;setBadge(j.unread||0);paint(j.items);'
            . 'var items=j.items||[],top=items[0];'
            . 'if(booted&&top&&String(top.id)!==String(lastTop)&&!top.is_read)toast(top);'
            . 'lastTop=top?top.id:null;booted=true})'
            . '.catch(function(){})};'
            // Refresh instantly when the tab regains focus (feels live without heavy polling).
            . 'document.addEventListener("visibilitychange",function(){if(!document.hidden)load()});'
            . 'window.addEventListener("focus",load);'
            . 'window.ppNotifyRefresh=load;'   // custom code can force an immediate refresh after an action
            . 'var clear=document.getElementById("ppNotifClear");'
            . 'if(clear)clear.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();'
            . 'fetch(u+"?action=read_all",{method:"POST",credentials:"same-origin"})'
            . '.then(function(){setBadge(0);'
            . '[].forEach.call(document.querySelectorAll(".pp-notif-item.unread"),function(el){el.classList.remove("unread")})})'
            . '.catch(function(){})});'
            . 'load();setInterval(load,30000);})();</script>';
    }

    // App-wide PDF API (window.PPpdf) — callable from custom code / click actions /
    // events. Lazy-loads html2pdf only when a PDF is actually requested.
    echo '<script>window.PP_BASE=window.PP_BASE||"' . htmlspecialchars(APP_BASE) . '";</script>'
        . '<script src="' . htmlspecialchars(APP_BASE) . '/assets/js/pp-pdf.js"></script>';
}
SHELLPHP;

        $written = [$this->writeFile($appDir . '/shell.php', $shellPhp . "\n")];
        $written[] = $this->writeFile($cssDir . '/shell.css', $this->shellCss($shellCfg) . $this->menuSpacingCss((array) ($this->ctx['menu']['spacing'] ?? [])));
        $this->log('Shell menu: ' . count($menuItems) . ' top-level item(s), brand "' . $brand . '"');
        return $written;
    }

    /**
     * Resolve the Menu Editor tree into shell items with app-relative routes.
     * Falls back to Dashboard + one entry per table when the menu is empty.
     */
    /**
     * The COMPLETE rendered menu = the designed/default menu (resolveMenu) plus
     * any auto-injected entries (the standalone Calendar app). ONE source of truth
     * so the generated shell and the Translations "Menu" catalogue list exactly the
     * same items with the same translation keys (menu.<tkey>).
     *
     * @return array<int,array<string,mixed>>
     */
    private function menuItemsResolved(array $tableNames): array
    {
        $items = $this->resolveMenu((array) ($this->ctx['menu'] ?? []), $tableNames);
        $calSettings = $this->calendarSettings();
        if (!empty($calSettings['enabled'])) {
            $items[] = [
                'label' => (string) ($calSettings['menuLabel'] ?? 'Calendar') ?: 'Calendar',
                'icon' => 'bi-calendar3', 'key' => 'calendar', 'tkey' => 'calendar',
                'href' => '/generated/calendar/index.php',
                'external' => false, 'blank' => false, 'roles' => [], 'children' => [],
            ];
        }
        return $items;
    }

    private function resolveMenu(array $items, array $tableNames): array
    {
        if ($items === []) {
            // Default menu: the translation key (tkey) mirrors the key so the Misc
            // editor's default-menu rows line up (menu.dashboard, menu.<table>).
            $out = [[
                'label' => 'Dashboard', 'icon' => 'bi-speedometer2', 'key' => 'dashboard', 'tkey' => 'dashboard',
                'href' => '', 'external' => false, 'blank' => false, 'roles' => [], 'children' => [],
            ]];
            foreach ($tableNames as $t) {
                if (!in_array('list', $this->pageTypesFor((string) $t), true)) continue;
                $out[] = [
                    'label' => ucwords(str_replace(['-', '_'], ' ', (string) $t)),
                    'icon' => 'bi-table', 'key' => (string) $t, 'tkey' => (string) $t,
                    'href' => '/generated/' . $t . '/list.php',
                    'external' => false, 'blank' => false, 'roles' => [], 'children' => [],
                ];
            }
            return $out;
        }

        $resolve = function (array $items) use (&$resolve, $tableNames): array {
            $out = [];
            foreach ($items as $item) {
                if (!is_array($item)) continue;
                $link = (array) ($item['link'] ?? []);
                $kind = (string) ($link['kind'] ?? 'page');
                $roles = [];
                foreach ((array) ($item['roles'] ?? []) as $r) {
                    $r = trim((string) $r);
                    if ($r !== '' && !in_array($r, $roles, true)) $roles[] = $r;
                }
                $isDivider = ($item['type'] ?? '') === 'divider';
                $node = [
                    'label' => (string) ($item['label'] ?? 'Item'),
                    'icon' => (string) ($item['icon'] ?? ''),
                    'key' => '',
                    // Stable per-item translation key (Misc → Menu tab → menu.<id>).
                    'tkey' => (string) ($item['id'] ?? ''),
                    'href' => '',
                    'external' => false,
                    'blank' => ($link['target'] ?? 'self') === 'blank',
                    'roles' => $roles,
                    'divider' => $isDivider,
                    'dividerStyle' => $isDivider && in_array($item['dividerStyle'] ?? 'line', ['line', 'dashed', 'dotted', 'gradient', 'label'], true) ? (string) $item['dividerStyle'] : 'line',
                    'children' => $resolve((array) ($item['children'] ?? [])),
                ];
                if ($kind === 'external' && !empty($link['url'])) {
                    $node['external'] = true;
                    $node['href'] = (string) $link['url'];
                } elseif ($kind === 'page') {
                    $page = (string) ($link['page'] ?? '');
                    if ($page === '') {
                        $node['key'] = 'dashboard';
                        $node['href'] = '';
                    } elseif (in_array($page, $tableNames, true)) {
                        $sub = (string) ($link['subpage'] ?? 'list') ?: 'list';
                        // A user-added custom page (`custom_<slug>`) is valid when it
                        // exists in the designer state — it isn't a Pages-module page
                        // type, so pageTypesFor() would otherwise clamp it to `list`.
                        $isCustom = str_starts_with($sub, 'custom_')
                            && !empty($this->ctx['designer']['pages'][$page][$sub]);
                        if (!$isCustom) {
                            $available = $this->pageTypesFor($page);
                            if (!in_array($sub, $available, true)) $sub = $available[0] ?? 'list';
                        }
                        $node['key'] = $page;
                        $node['href'] = '/generated/' . $page . '/' . $sub . '.php';
                        if (!empty($link['params'])) $node['href'] .= '?' . ltrim((string) $link['params'], '?&');
                    }
                }
                $out[] = $node;
            }
            return $out;
        };

        return $resolve($items);
    }

    /**
     * Sidebar spacing overrides from the Menu Editor (item height / padding /
     * gap / divider gap). Appended after shellCss so it wins. Each value is
     * clamped to the same range the editor enforces.
     *
     * @param array<string, mixed> $spacing
     */
    private function menuSpacingCss(array $spacing): string
    {
        $clamp = static fn ($v, int $lo, int $hi, int $def): int => is_numeric($v) ? max($lo, min($hi, (int) $v)) : $def;
        $h = $clamp($spacing['height'] ?? null, 26, 64, 38);
        $padX = $clamp($spacing['padX'] ?? null, 4, 28, 12);
        $gap = $clamp($spacing['gap'] ?? null, 0, 16, 2);
        $divGap = $clamp($spacing['divGap'] ?? null, 2, 28, 8);
        $labelGap = $divGap + 6;
        return <<<CSS

/* ── Menu spacing (Menu Editor) ── */
.pp-menu { gap: {$gap}px; }
.pp-menu-link, .pp-menu-group summary { min-height: {$h}px; padding-top: 4px; padding-bottom: 4px; padding-left: {$padX}px; padding-right: {$padX}px; }
.pp-menu-divider { margin-top: {$divGap}px; margin-bottom: {$divGap}px; }
.pp-menu-divider-label { margin-top: {$labelGap}px; margin-bottom: 5px; }

CSS;
    }

    /** Modern responsive shell stylesheet, themed by the compiled --pp-* variables. */
    private function shellCss(array $shellCfg): string
    {
        $topH = preg_replace('/[^0-9a-z.]/i', '', (string) ($shellCfg['topbar']['height'] ?? '56px')) ?: '56px';
        $sideW = preg_replace('/[^0-9a-z.]/i', '', (string) ($shellCfg['sidebar']['width'] ?? '250px')) ?: '250px';

        return <<<CSS
/* Application shell — generated from the Style designer. */
:root { --pp-shell-topbar-h: var(--pp-topbar-h, {$topH}); --pp-shell-sidebar-w: var(--pp-sidebar-w, {$sideW}); }

/* Chromeless "Standalone / Landing" pages (page frame = blank): no topbar,
   sidebar or footer — the page owns the full viewport and supplies its own
   Navbar/Footer components. Theme tokens still apply. */
.pp-landing-body { margin: 0; min-height: 100vh; background: var(--pp-bg, var(--pp-surface, #fff)); color: var(--pp-text, #0f172a); }
.pp-landing { display: block; width: 100%; }

/* ── Navbar layout variants ──────────────────────────────────────────────────
   The Style module's navbar type ships as a class on .pp-shell. Every rule
   below is token-driven, so a variant inherits the active theme (and its
   light/dark shell veils) without declaring a single colour of its own. */

/* Menu rendered horizontally inside the topbar (horizontal / boxed / hero).
   pp_menu_html() emits a VERTICAL structure (ul.pp-menu with li.pp-menu-divider
   captions/rules); the rules below re-flow it into a single row rather than
   requiring a second renderer. */
/* overflow must stay visible so the absolutely-positioned dropdowns (which sit
   below the bar) aren't clipped; the responsive @media stacks the menu instead. */
.pp-topnav { display: flex; align-items: center; min-width: 0; margin-left: 10px; overflow: visible; }
/* flex-direction MUST be restated: the shared .pp-menu rule is column. */
.pp-topnav > ul.pp-menu { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; gap: 2px; list-style: none; margin: 0; padding: 0; }
.pp-topnav > ul.pp-menu > li { position: relative; flex: 0 0 auto; }
.pp-topnav .pp-menu-link {
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
  padding: 7px 11px; border-radius: var(--pp-radius-sm); color: var(--pp-topbar-text);
  text-decoration: none; font-size: .87rem; font-weight: 600; border: 0;
}
.pp-topnav .pp-menu-link:hover { background: var(--pp-topbar-hover); color: var(--pp-sidebar-active); }
.pp-topnav .pp-menu-active { background: var(--pp-topbar-active-bg); color: var(--pp-sidebar-active); }
/* Sidebar dividers/captions make no sense on one line: label + decorative
   variants are dropped, a plain line becomes a slim vertical rule. */
.pp-topnav .pp-menu-divider { flex: 0 0 auto; }
.pp-topnav .pp-menu-divider-label,
.pp-topnav .pp-menu-divider-dashed,
.pp-topnav .pp-menu-divider-gradient { display: none; }
.pp-topnav .pp-menu-divider-line {
  width: 1px; height: 18px; margin: 0 6px; padding: 0; border: 0;
  background: var(--pp-topbar-border); border-radius: 0;
}
/* Top-level groups render as a <button> trigger + positioned submenu — an
   animated dropdown, opened on hover OR click (.pp-open, toggled by JS) so it
   works on touch too. */
.pp-topnav li.pp-menu-group { position: relative; }
.pp-topnav .pp-menu-toggle { cursor: pointer; font: inherit; background: transparent; }
.pp-topnav li.pp-menu-group > ul.pp-menu-sub {
  position: absolute; top: calc(100% + 7px); left: 0; min-width: 210px; max-width: 300px;
  padding: 6px; margin: 0; list-style: none; display: flex; flex-direction: column; gap: 2px;
  border-radius: var(--pp-radius); background: var(--pp-surface); border: 1px solid var(--pp-border);
  box-shadow: var(--pp-shadow-lg); z-index: 1050;
  opacity: 0; transform: translateY(-8px) scale(.97); transform-origin: top left; visibility: hidden;
  transition: opacity .17s ease, transform .17s ease, visibility 0s linear .17s;
}
.pp-topnav li.pp-menu-group:hover > ul.pp-menu-sub,
.pp-topnav li.pp-menu-group.pp-open > ul.pp-menu-sub {
  opacity: 1; transform: none; visibility: visible; transition-delay: 0s;
}
.pp-topnav li.pp-menu-group.pp-flip > ul.pp-menu-sub { left: auto; right: 0; transform-origin: top right; }
.pp-topnav li.pp-menu-group:hover > .pp-menu-toggle .pp-menu-caret,
.pp-topnav li.pp-menu-group.pp-open > .pp-menu-toggle .pp-menu-caret { transform: rotate(180deg); }
.pp-topnav ul.pp-menu-sub .pp-menu-link,
.pp-topnav ul.pp-menu-sub .pp-menu-toggle { color: var(--pp-text); width: 100%; justify-content: flex-start; white-space: nowrap; }
.pp-topnav ul.pp-menu-sub .pp-menu-link:hover { background: var(--pp-row-hover); color: var(--pp-primary); }
.pp-topnav ul.pp-menu-sub details > summary { list-style: none; cursor: pointer; }
.pp-topnav ul.pp-menu-sub details > summary::-webkit-details-marker { display: none; }
/* ── Menu placement: topbar on desktop, sidebar drawer on mobile ─────────────
   A horizontal topbar menu is great on wide screens but cramped on a phone, so
   it is NEVER shown there: below 992px the topbar menu is hidden and the same
   menu is served through the off-canvas sidebar drawer (opened by the burger) —
   identical to sidebar placement. The build renders BOTH the .pp-topnav and the
   #ppSidebar <aside> for a topbar layout; these rules pick which one is live.
   Desktop (≥992px): topbar menu visible, drawer + burger hidden, content full
   width. Mobile (<992px): topbar menu hidden, drawer takes over. */
@media (min-width: 992px) {
  .pp-shell.menu-topbar .pp-sidebar { display: none; }
  .pp-shell.menu-topbar .pp-main { margin-left: 0; margin-right: 0; }
  .pp-shell.menu-topbar .pp-burger { display: none; }
}
@media (max-width: 991.98px) {
  /* The topbar menu never appears on small screens — the drawer replaces it. */
  .pp-shell.menu-topbar .pp-topnav { display: none; }
  /* The drawer is a real fixed sidebar again (any variant width normalises). */
  .pp-shell.menu-topbar .pp-sidebar { display: flex; width: var(--pp-sidebar-w, 250px); }
}

/* Topbar scroll behaviour (Style → Layout): fixed keeps it pinned, static lets
   it scroll away. Content padding follows so nothing hides under a pinned bar. */
/* ── Mini sidebar (Auto hide) ────────────────────────────────────────────────
   A real minimal state: icons only, at the same width the icon-rail layout
   uses, so the two share one visual language. Hover peeks the labels back —
   without that, a collapsed menu would be unusable rather than compact.
   NOTE: this block must stay at TOP LEVEL. The desktop-collapse rules live
   inside @media (min-width:992px), and nesting a second @media inside that is
   invalid CSS — it closes the outer block early and re-parents everything after. */
@media (min-width: 992px) {
  body.pp-nav-mini .pp-sidebar { width: var(--pp-rail-w, 72px); overflow: visible; transition: width .16s ease; }
  body.pp-nav-mini .pp-main { margin-left: var(--pp-rail-w, 72px); transition: margin-left .16s ease; }
  body.pp-nav-mini .pp-shell.side-right .pp-main { margin-left: 0; margin-right: var(--pp-rail-w, 72px); }
  body.pp-nav-mini .pp-sidebar .pp-menu-link { justify-content: center; padding-left: 0; padding-right: 0; }
  body.pp-nav-mini .pp-sidebar .pp-menu-link > span,
  body.pp-nav-mini .pp-sidebar .pp-sidebar-foot span { display: none; }
  body.pp-nav-mini .pp-sidebar .pp-menu-divider-label { font-size: 0; padding: 0; height: 1px; overflow: hidden; }
  body.pp-nav-mini .pp-sidebar:hover,
  body.pp-nav-mini .pp-sidebar:focus-within { width: var(--pp-rail-expanded-w, 230px); box-shadow: var(--pp-shadow-lg); }
  body.pp-nav-mini .pp-sidebar:is(:hover, :focus-within) .pp-menu-link { justify-content: flex-start; padding-left: 14px; }
  body.pp-nav-mini .pp-sidebar:is(:hover, :focus-within) .pp-menu-link > span,
  body.pp-nav-mini .pp-sidebar:is(:hover, :focus-within) .pp-sidebar-foot span { display: inline; }
  body.pp-nav-mini .pp-sidebar:is(:hover, :focus-within) .pp-menu-divider-label {
    font-size: .68rem; padding: revert; height: auto; overflow: visible; }
  /* An explicit collapse always beats auto-hide — the user asked for it. */
  body.pp-nav-collapsed.pp-nav-mini .pp-sidebar { transform: translateX(-100%); }
  body.pp-nav-collapsed.pp-nav-mini .pp-main { margin-left: 0; margin-right: 0; }
}
@media (prefers-reduced-motion: reduce) {
  body.pp-nav-mini .pp-sidebar, body.pp-nav-mini .pp-main { transition: none; }
}
/* Long menus: fade the edges so it is obvious there is more to scroll to. */
.pp-sidebar-nav { position: relative; }
.pp-sidebar.has-overflow-top::before, .pp-sidebar.has-overflow-bottom::after {
  content: ''; position: sticky; left: 0; right: 0; height: 18px; display: block;
  pointer-events: none; z-index: 2; }
.pp-sidebar.has-overflow-top::before { top: 0; background: linear-gradient(var(--pp-sidebar-bg), transparent); }
.pp-sidebar.has-overflow-bottom::after { bottom: 0; background: linear-gradient(transparent, var(--pp-sidebar-bg)); }

/* ── Accessibility & motion ──────────────────────────────────────────────────
   Off-screen until focused, then it parks itself over the topbar. */
.pp-skip { position: fixed; top: 8px; left: 8px; z-index: 2000; transform: translateY(-200%);
  padding: 9px 14px; border-radius: var(--pp-radius-sm); font-weight: 700; font-size: .87rem;
  background: var(--pp-primary); color: var(--pp-primary-text, #fff); text-decoration: none;
  box-shadow: var(--pp-shadow-lg); transition: transform .14s ease; }
.pp-skip:focus { transform: translateY(0); color: var(--pp-primary-text, #fff); }
.pp-main:focus { outline: none; }
/* One visible focus treatment for every interactive part of the shell. Uses
   :focus-visible so pointer users never see a ring they did not ask for. */
.pp-topbar :is(a, button, summary, input):focus-visible,
.pp-sidebar :is(a, button, summary):focus-visible {
  outline: 2px solid var(--pp-on-shell, #fff); outline-offset: 2px; border-radius: var(--pp-radius-sm); }
.pp-main :is(a, button, summary):focus-visible {
  outline: 2px solid var(--pp-focus, var(--pp-primary)); outline-offset: 2px; }
/* While the mobile drawer is open the page behind it must not scroll. */
body.pp-nav-locked { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  .pp-sidebar, .pp-skip, .pp-nav-overlay { transition: none !important; }
}
/* Unpinned sidebar: scrolls away with the content instead of staying put. */
.pp-shell.sidebar-static .pp-sidebar { position: sticky; top: var(--pp-shell-topbar-h);
  height: calc(100vh - var(--pp-shell-topbar-h)); }
@media (max-width: 991.98px) { .pp-shell.sidebar-static .pp-sidebar { position: fixed; top: 0; height: 100%; } }
/* Long menus get a hint that there is more below. */
.pp-sidebar-nav { scroll-behavior: smooth; }

.pp-shell.topbar-static .pp-topbar { position: static; }
.pp-shell.topbar-static .pp-main { padding-top: 0; }
.pp-shell.topbar-static .pp-sidebar { top: 0; }

/* No sidebar → content reclaims the full width. */
.pp-shell.no-sidebar .pp-main { margin-left: 0; margin-right: 0; }
.pp-shell.no-topbar .pp-main { padding-top: 0; }
.pp-shell.no-topbar .pp-sidebar { top: 0; }

/* Sidebar on the right. */
.pp-shell.side-right .pp-sidebar { left: auto; right: 0; }
.pp-shell.side-right .pp-main { margin-left: 0; margin-right: var(--pp-shell-sidebar-w); }

/* Icon rail — collapsed to icons, expands on hover to reveal labels. */
.pp-shell.has-rail { --pp-shell-sidebar-w: var(--pp-rail-w, 72px); }
.pp-shell.has-rail .pp-sidebar { width: var(--pp-rail-w, 72px); overflow: visible; transition: width .16s ease; }
.pp-shell.has-rail .pp-sidebar .pp-menu-link { justify-content: center; padding-left: 0; padding-right: 0; }
.pp-shell.has-rail .pp-sidebar .pp-menu-link > span,
.pp-shell.has-rail .pp-sidebar .pp-sidebar-foot span { display: none; }
/* Section captions have no room in a 72px rail (the class is
   .pp-menu-divider-label, not .pp-menu-cap). */
.pp-shell.has-rail .pp-sidebar .pp-menu-divider-label { font-size: 0; padding: 0; height: 1px; overflow: hidden; }
.pp-shell.has-rail .pp-sidebar:hover .pp-menu-divider-label { font-size: .68rem; padding: revert; height: auto; overflow: visible; }
.pp-shell.has-rail .pp-sidebar:hover { width: var(--pp-rail-expanded-w, 230px); box-shadow: var(--pp-shadow-lg); }
.pp-shell.has-rail .pp-sidebar:hover .pp-menu-link { justify-content: flex-start; padding-left: 14px; }
.pp-shell.has-rail .pp-sidebar:hover .pp-menu-link > span,
.pp-shell.has-rail .pp-sidebar:hover .pp-sidebar-foot span { display: inline; }

/* Boxed — a centred, contained app with a detached topbar. */
.pp-shell.nb-boxed { max-width: var(--pp-boxed-w, 1240px); margin: 0 auto; }
.pp-shell.nb-boxed .pp-topbar {
  position: static; margin: 12px; border-radius: var(--pp-radius-lg);
  border: 1px solid var(--pp-topbar-border);
}
.pp-shell.nb-boxed .pp-main { padding-top: 0; }

/* Hero — tall branded bar, menu sitting beneath the title. */
.pp-shell.nb-hero .pp-topbar {
  position: static; height: auto; min-height: var(--pp-hero-h, 84px); flex-wrap: wrap;
  align-items: center; padding-top: 12px; padding-bottom: 12px;
  background: linear-gradient(var(--pp-hero-angle, 135deg), var(--pp-topbar-bg), var(--pp-primary));
}
.pp-shell.nb-hero .pp-brand-name { font-size: 1.25rem; }
.pp-shell.nb-hero .pp-topnav { flex: 1 1 100%; margin-left: 0; overflow-x: auto; }
.pp-shell.nb-hero .pp-main { padding-top: 0; }

/* Sidebar-led — slimmer topbar, the sidebar owns the full height. */
.pp-shell.nb-sidebar .pp-sidebar { top: 0; z-index: 1035; }
.pp-shell.nb-sidebar .pp-topbar { left: var(--pp-shell-sidebar-w); }
.pp-shell.nb-sidebar.side-right .pp-topbar { left: 0; right: var(--pp-shell-sidebar-w); }

/* 991.98 (not 992) so it dovetails exactly with the min-width:992 desktop rules
   — at precisely 992px the desktop layout owns the screen, with no 1px dead zone
   where both the topbar menu and the drawer would be hidden at once. */
@media (max-width: 991.98px) {
  /* Small screens always fall back to the drawer pattern. */
  .pp-shell.has-rail .pp-sidebar, .pp-shell.side-right .pp-sidebar { width: var(--pp-sidebar-w, 250px); }
  .pp-shell.side-right .pp-main, .pp-shell.nb-sidebar .pp-topbar { margin-right: 0; left: 0; right: 0; }
  .pp-topnav { display: none; }
}

/* ── Bootstrap bridge ────────────────────────────────────────────────────────
   Bootstrap ships its own --bs-* custom properties. Without this, every
   Bootstrap-styled element (links, buttons, badges, alerts, form controls,
   tables, modals) keeps Bootstrap's stock palette and simply ignores the
   selected theme — the reason detail carets stayed Bootstrap blue on a green
   theme. Re-pointing --bs-* at our tokens themes all of them at once, and the
   .is-dark block re-points them again for dark mode. */
:root, :root.is-dark {
  --bs-body-bg: var(--pp-bg);
  --bs-body-color: var(--pp-text);
  --bs-emphasis-color: var(--pp-text);
  --bs-secondary-color: var(--pp-muted);
  --bs-border-color: var(--pp-border);
  --bs-border-radius: var(--pp-radius);
  --bs-tooltip-bg: var(--pp-tooltip-bg);
  --bs-tooltip-color: var(--pp-tooltip-text);
  --bs-tooltip-opacity: 1;
  --bs-link-color: var(--pp-link);
  --bs-link-hover-color: var(--pp-link-hover);
  --bs-link-color-rgb: initial;
  --bs-primary: var(--pp-primary);
  --bs-success: var(--pp-success);
  --bs-warning: var(--pp-warning);
  --bs-danger: var(--pp-danger);
  --bs-info: var(--pp-info);
  --bs-secondary: var(--pp-muted);
  --bs-font-sans-serif: var(--pp-font);
  --bs-font-monospace: var(--pp-mono);
}
/* Bootstrap composes some colours from *-rgb triplets, which a var() cannot
   supply — restate the handful of surfaces we actually use. */
.badge.bg-primary { background-color: var(--pp-primary) !important; color: var(--pp-primary-text); }
.badge.bg-success { background-color: var(--pp-success) !important; color: var(--pp-on-success, #fff); }
.badge.bg-danger  { background-color: var(--pp-danger)  !important; color: var(--pp-on-danger, #fff); }
.badge.bg-warning { background-color: var(--pp-warning) !important; color: var(--pp-on-warning, #fff); }
.badge.bg-info    { background-color: var(--pp-info)    !important; color: var(--pp-on-info, #fff); }
.badge.bg-secondary { background-color: var(--pp-muted) !important; color: var(--pp-primary-text); }
a { color: var(--pp-link); }
a:hover { color: var(--pp-link-hover); }
.form-control, .form-select {
  background-color: var(--pp-input-bg); color: var(--pp-input-text);
  border-color: var(--pp-input-border);
}
.form-control::placeholder { color: var(--pp-placeholder); }
.form-control:focus, .form-select:focus {
  border-color: var(--pp-focus); box-shadow: 0 0 0 3px var(--pp-focus-ring);
}
.alert-success { background: var(--pp-success-soft); border-color: var(--pp-success-border); color: var(--pp-success-text); }
.alert-warning { background: var(--pp-warning-soft); border-color: var(--pp-warning-border); color: var(--pp-warning-text); }
.alert-danger  { background: var(--pp-danger-soft);  border-color: var(--pp-danger-border);  color: var(--pp-danger-text); }
.alert-info    { background: var(--pp-info-soft);    border-color: var(--pp-info-border);    color: var(--pp-info-text); }
body { margin: 0; font-family: var(--pp-font, 'Inter', system-ui, sans-serif); background: var(--pp-bg, #f6f9fd); color: var(--pp-text, #0f172a); }

/* ── Topbar ── */
.pp-topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 1030; height: var(--pp-shell-topbar-h);
  display: flex; align-items: center; gap: .75rem; padding: 0 1rem;
  background: var(--pp-topbar-bg, #0f172a); color: var(--pp-topbar-text, #cbd5e1);
  box-shadow: inset 0 -1px 0 var(--pp-topbar-border), var(--pp-shadow-sm); }
.pp-brand { display: flex; align-items: center; gap: .6rem; text-decoration: none; color: inherit; }
.pp-brand-mark { width: 32px; height: 32px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
  background: var(--pp-topbar-accent, #3b82f6); color:var(--pp-primary-text,#fff); font-weight: 800; font-size: .8rem; letter-spacing: .02em; }
.pp-brand-name { font-weight: 700; font-size: 1.05rem; color:var(--pp-topbar-text,#fff); }
.pp-brand-img { height: 32px; width: auto; border-radius: 7px; object-fit: contain; }
.pp-topbar-spacer { flex: 1; }

/* ── Topbar finish (Style → Layout → Topbar) ─────────────────────────────────
   Every value below is a theme token, so each finish re-skins with the theme. */
.pp-topbar.has-grad { background: linear-gradient(100deg, var(--pp-topbar-bg), var(--pp-primary)); }
.pp-topbar.elev-none   { box-shadow: none; }
.pp-topbar.elev-border { box-shadow: inset 0 -1px 0 var(--pp-topbar-border); }
.pp-topbar.elev-shadow { box-shadow: inset 0 -1px 0 var(--pp-topbar-border), var(--pp-shadow-sm); }
/* Frosted bar. `color-mix` degrades to the solid token where unsupported, and
   the backdrop filter is simply ignored — so this never breaks a browser. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .pp-topbar.has-blur {
    background: color-mix(in srgb, var(--pp-topbar-bg) 78%, transparent);
    -webkit-backdrop-filter: saturate(180%) blur(12px);
    backdrop-filter: saturate(180%) blur(12px);
  }
  .pp-topbar.has-blur.has-grad {
    background: linear-gradient(100deg,
      color-mix(in srgb, var(--pp-topbar-bg) 78%, transparent),
      color-mix(in srgb, var(--pp-primary)    78%, transparent));
  }
}
/* Compact-on-scroll. Only the bar shrinks — --pp-shell-topbar-h stays put so
   the main content does not jump as the header resizes. */
.pp-topbar.can-shrink { transition: height .18s ease, box-shadow .18s ease; }
.pp-topbar.can-shrink.is-shrunk { height: calc(var(--pp-shell-topbar-h) * 0.76); }
.pp-topbar.can-shrink.is-shrunk .pp-brand-name { opacity: 0; transform: translateX(-4px); }
.pp-topbar.can-shrink .pp-brand-name { transition: opacity .18s ease, transform .18s ease; }
@media (prefers-reduced-motion: reduce) {
  .pp-topbar.can-shrink, .pp-topbar.can-shrink .pp-brand-name { transition: none; }
}

/* ── Quick find ──────────────────────────────────────────────────────────── */
.pp-tb-search { position: relative; display: flex; align-items: center; gap: 7px; margin-right: 8px;
  padding: 5px 9px; border-radius: var(--pp-radius-sm); min-width: 0; flex: 0 1 260px;
  background: var(--pp-topbar-hover); color: var(--pp-topbar-text); }
.pp-tb-search:focus-within { box-shadow: 0 0 0 2px var(--pp-topbar-active-bg); }
.pp-tb-search > i { flex: 0 0 auto; opacity: .8; font-size: .9rem; }
.pp-tb-search input { flex: 1 1 auto; min-width: 0; border: 0; outline: 0; background: transparent;
  color: inherit; font: inherit; font-size: .86rem; }
.pp-tb-search input::placeholder { color: currentColor; opacity: .62; }
.pp-tb-kbd { flex: 0 0 auto; font-family: var(--pp-mono); font-size: .66rem; line-height: 1; padding: 3px 5px;
  border-radius: 4px; opacity: .7; background: var(--pp-topbar-active-bg); }
.pp-tb-search:focus-within .pp-tb-kbd { display: none; }
/* min-width matters: when the field collapses to its icon on narrow screens the
   panel would otherwise inherit that ~36px width and shred every label. */
.pp-quick { position: absolute; top: calc(100% + 7px); left: 0; right: 0; min-width: 240px; z-index: 1050; overflow: hidden;
  padding: 5px; border-radius: var(--pp-radius); background: var(--pp-surface-raised);
  border: 1px solid var(--pp-border); box-shadow: var(--pp-shadow-lg); max-height: 320px; overflow-y: auto; }
.pp-quick a { display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: var(--pp-radius-sm);
  color: var(--pp-text); text-decoration: none; font-size: .86rem; }
.pp-quick a:hover, .pp-quick a.is-on { background: var(--pp-row-hover); color: var(--pp-primary); }
.pp-quick .pp-quick-empty { padding: 10px; text-align: center; color: var(--pp-muted); font-size: .82rem; }
/* On narrow screens the field collapses to its icon so the bar never wraps. */
@media (max-width: 767.98px) {
  .pp-tb-search { flex: 0 0 auto; }
  .pp-tb-search input, .pp-tb-kbd { display: none; }
  .pp-tb-search:focus-within { flex: 1 1 auto; }
  .pp-tb-search:focus-within input { display: block; }
  /* anchor the panel to the right edge so a min-width box cannot overflow */
  .pp-quick { left: auto; right: 0; max-width: calc(100vw - 24px); }
  /* Language selector → FLAG ONLY on phones, so the brand name keeps its room
     (the full name + chevron squeezed the brand into "Legal C…"). The dropdown
     menu still lists every language with its name. */
  .pp-lang > summary .pp-lang-name,
  .pp-lang > summary > .bi-chevron-down { display: none; }
  .pp-lang > summary { gap: 0; padding: .3rem .4rem; }
}
.pp-burger { background:var(--pp-topbar-hover,rgba(255,255,255,.06)); border:1px solid var(--pp-topbar-border,rgba(255,255,255,.1)); color: inherit;
  font-size: 1.15rem; cursor: pointer; padding: .3rem .55rem; border-radius: 9px; line-height: 1;
  transition: background .15s, transform .1s; }
.pp-burger:hover { background:var(--pp-sidebar-active-bg,rgba(255,255,255,.14)); }
.pp-burger:active { transform: scale(.94); }

/* desktop sidebar collapse */
.pp-sidebar, .pp-main { transition: transform .22s ease, margin-left .22s ease; }
@media (min-width: 992px) {
  body.pp-nav-collapsed .pp-sidebar { transform: translateX(-100%); }
  body.pp-nav-collapsed .pp-main { margin-left: 0; }
}

/* topbar widgets: language, notifications, dark toggle */
.pp-lang, .pp-notif { position: relative; }
.pp-lang summary, .pp-notif summary { list-style: none; display: flex; align-items: center; gap: .4rem; cursor: pointer;
  padding: .35rem .55rem; border-radius: 8px; font-size: .85rem; position: relative; }
.pp-lang summary::-webkit-details-marker, .pp-notif summary::-webkit-details-marker { display: none; }
.pp-lang summary:hover, .pp-notif summary:hover { background:var(--pp-sidebar-hover,rgba(255,255,255,.08)); }
.pp-lang .pp-user-menu, .pp-notif .pp-user-menu { position: absolute; right: 0; top: calc(100% + 8px); }
.pp-notif summary i.bi-bell { font-size: 1.15rem; }
.pp-notif-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; border-radius: 999px;
  background: var(--pp-danger, #dc2626); color:var(--pp-primary-text,#fff); font-size: .62rem; font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; }
/* Live bell — a soft red ring keeps pulsing while there are unread messages, and
   the bell swings + the badge pops once each time a NEW one arrives. */
.pp-notif summary i.bi-bell { transition: color .2s; }
.pp-notif.has-unread summary i.bi-bell { color: var(--pp-danger, #dc2626); }
.pp-notif.has-unread .pp-notif-badge { animation: pp-notif-pulse 1.9s ease-out infinite; }
.pp-notif-badge.pop { animation: pp-badge-pop .34s cubic-bezier(.3, 1.4, .5, 1); }
.pp-notif.ring summary i.bi-bell { animation: pp-bell-swing 1s ease; transform-origin: 50% 12%; }
@keyframes pp-notif-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--pp-danger, #dc2626) 60%, transparent); }
  70%  { box-shadow: 0 0 0 7px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}
@keyframes pp-badge-pop { 0% { transform: scale(.35); } 60% { transform: scale(1.18); } 100% { transform: scale(1); } }
@keyframes pp-bell-swing {
  0%, 100% { transform: rotate(0); }
  10% { transform: rotate(16deg); } 22% { transform: rotate(-13deg); }
  34% { transform: rotate(10deg); } 46% { transform: rotate(-7deg); }
  58% { transform: rotate(4deg); }  70% { transform: rotate(-2deg); }
}
@media (prefers-reduced-motion: reduce) {
  .pp-notif.has-unread .pp-notif-badge, .pp-notif-badge.pop, .pp-notif.ring summary i.bi-bell { animation: none; }
}
/* Notifications — same anatomy as the console bell (core/views/notification-bell.php):
   titled header with a Mark-all-read action, icon chip + title + message +
   relative time per row, and a reassuring empty state. */
/* The element carries BOTH classes and `.pp-user-menu` (min-width:190px) is
   declared later, so a bare `.pp-notif-menu` loses on source order and the
   panel rendered at 190px with badly wrapped text. Combine both classes. */
.pp-user-menu.pp-notif-menu { min-width: 340px; max-width: min(380px, calc(100vw - 24px)); padding: 0; overflow: hidden; }
.pp-notif-head { display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: .7rem .85rem; border-bottom: 1px solid var(--pp-border, #eef1f6);
  background: var(--pp-surface-soft, #f8fafc); }
.pp-notif-head strong { font-size: .82rem; font-weight: 750; color: var(--pp-text, #1e293b); }
.pp-notif-clear { border: 0; background: transparent; color: var(--pp-primary); cursor: pointer;
  font-size: .72rem; font-weight: 700; padding: 2px 4px; border-radius: 5px; }
.pp-notif-clear:hover { text-decoration: underline; }
.pp-notif-list { max-height: 340px; overflow-y: auto; }
.pp-notif-item { display: flex; gap: 11px; padding: .7rem .85rem; border-radius: 0;
  border-bottom: 1px solid var(--pp-border, #f1f5f9);
  text-decoration: none; color: inherit; font-size: .85rem; }
.pp-notif-item:last-child { border-bottom: 0; }
.pp-notif-item:hover { background: var(--pp-row-hover, #f8fafc); }
.pp-notif-item.unread { background: color-mix(in srgb, var(--pp-primary) 7%, var(--pp-surface, #fff)); }
.pp-notif-ic { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 9px; display: flex;
  align-items: center; justify-content: center; font-size: .85rem; color: var(--pp-primary);
  background: color-mix(in srgb, var(--pp-primary) 13%, var(--pp-surface, #fff)); }
.pp-notif-body { min-width: 0; flex: 1 1 auto; }
/* each line is a span inside an <a>, so it needs an explicit block */
.pp-notif-title { display: block; font-size: .8rem; font-weight: 700; color: var(--pp-text); }
.pp-notif-text { display: block; font-size: .75rem; line-height: 1.5; color: var(--pp-muted, #64748b);
  margin-top: 1px; overflow-wrap: anywhere; }
.pp-notif-time { display: block; font-size: .66rem; color: var(--pp-muted, #94a3b8); margin-top: 3px; }
.pp-notif-empty { padding: 2rem 1.1rem; text-align: center; color: var(--pp-muted, #64748b); font-size: .78rem; }
.pp-notif-empty i { display: block; font-size: 1.6rem; margin-bottom: .5rem; opacity: .5; }
/* Notification severity colours — the item icon + unread accent follow the type. */
.pp-notif-item { --nt: var(--pp-primary, #2563eb); }
.pp-notif-item.pp-nt-success { --nt: #16a34a; }
.pp-notif-item.pp-nt-warning { --nt: #d97706; }
.pp-notif-item.pp-nt-error   { --nt: #dc2626; }
.pp-notif-item .pp-notif-ic { color: var(--nt); background: color-mix(in srgb, var(--nt) 14%, var(--pp-surface, #fff)); }
.pp-notif-item.unread { box-shadow: inset 3px 0 0 var(--nt); }
/* Read vs unread: read rows recede (dim + desaturated icon), unread stay sharp
   with a bold title and a crisp status dot. Hover restores a read row. */
.pp-notif-item { position: relative; transition: opacity .15s ease, background-color .15s ease; }
.pp-notif-item:not(.unread) { opacity: .56; }
.pp-notif-item:not(.unread) .pp-notif-ic { filter: grayscale(.55); }
.pp-notif-item:not(.unread):hover { opacity: 1; }
.pp-notif-item.unread .pp-notif-title { font-weight: 800; }
.pp-notif-item.unread::after { content: ""; position: absolute; top: .8rem; right: .8rem; width: 8px; height: 8px;
  border-radius: 50%; background: var(--nt); box-shadow: 0 0 0 3px color-mix(in srgb, var(--nt) 22%, transparent); }
/* Slide-in toast when a new notification arrives */
.pp-notif-toast { position: fixed; right: 18px; bottom: 18px; z-index: 6000; display: flex; gap: 11px; align-items: flex-start;
  max-width: 340px; padding: 12px 14px 12px 18px; border-radius: 12px; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); box-shadow: 0 18px 44px -10px rgba(15, 23, 42, .4); --nt: var(--pp-primary, #2563eb);
  transform: translateY(16px); opacity: 0; transition: transform .3s cubic-bezier(.3, 1.2, .5, 1), opacity .3s; }
.pp-notif-toast.show { transform: translateY(0); opacity: 1; }
.pp-notif-toast.pp-nt-success { --nt: #16a34a; } .pp-notif-toast.pp-nt-warning { --nt: #d97706; } .pp-notif-toast.pp-nt-error { --nt: #dc2626; }
.pp-notif-toast::before { content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 4px; border-radius: 0 4px 4px 0; background: var(--nt); }
.pp-notif-toast-ic { flex: 0 0 34px; width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; font-size: 1rem;
  color: var(--nt); background: color-mix(in srgb, var(--nt) 14%, var(--pp-surface, #fff)); }
.pp-notif-toast-b { min-width: 0; font-size: .8rem; line-height: 1.45; }
.pp-notif-toast-b b { display: block; font-weight: 700; color: var(--pp-text); }
.pp-notif-toast-b span { color: var(--pp-muted, #64748b); overflow-wrap: anywhere; }
@media (max-width: 480px) { .pp-notif-toast { left: 12px; right: 12px; max-width: none; } }
@media (prefers-reduced-motion: reduce) { .pp-notif-toast { transition: opacity .2s; transform: none; } }
.pp-flag { font-size: 1.05rem; line-height: 1; }
/* theme switcher (System / Light / Dark) */
.pp-theme { position: relative; }
.pp-theme summary { list-style: none; display: flex; align-items: center; gap: .28rem; cursor: pointer;
  color: inherit; font-size: 1.05rem; padding: .35rem .5rem; border-radius: 8px; }
.pp-theme summary::-webkit-details-marker { display: none; }
.pp-theme summary:hover { background:var(--pp-sidebar-hover,rgba(255,255,255,.08)); }
.pp-theme-caret { font-size: .7rem; opacity: .7; }
.pp-theme .pp-user-menu { min-width: 172px; }
.pp-theme-menu { display: flex; flex-direction: column; gap: 1px; }
.pp-theme-opt { display: flex; align-items: center; gap: .55rem; width: 100%; padding: .5rem .65rem; border: 0;
  background: transparent; color: inherit; font: inherit; font-size: .875rem; text-align: left; cursor: pointer;
  border-radius: 8px; }
.pp-theme-opt i:first-child { width: 18px; text-align: center; flex: 0 0 18px; }
.pp-theme-opt span { flex: 1; }
.pp-theme-opt:hover { background: var(--pp-primary-soft, #eff6ff); color: var(--pp-primary, #2563eb); }
.pp-theme-ck { opacity: 0; font-size: .95rem; }
.pp-theme-opt[aria-checked="true"] { color: var(--pp-primary, #2563eb); font-weight: 600; }
.pp-theme-opt[aria-checked="true"] .pp-theme-ck { opacity: 1; }

/* GDPR banner */
.pp-gdpr { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 1100;
  display: flex; align-items: center; gap: 1rem; max-width: min(680px, calc(100vw - 32px));
  background: var(--pp-text, #0f172a); color:var(--pp-primary-text,#fff); padding: .8rem 1.1rem; border-radius: 14px;
  font-size: .85rem; box-shadow:var(--pp-shadow-lg,0 16px 40px rgba(2,6,23,.35)); }
.pp-gdpr button { background: var(--pp-primary, #2563eb); color:var(--pp-primary-text,#fff); border: 0; border-radius: 8px;
  padding: .4rem 1rem; font-weight: 600; cursor: pointer; flex: 0 0 auto; }

/* user dropdown */
.pp-user { position: relative; }
.pp-user summary { list-style: none; display: flex; align-items: center; gap: .5rem; cursor: pointer; padding: .3rem .5rem; border-radius: 8px; }
.pp-user summary::-webkit-details-marker { display: none; }
.pp-user summary:hover { background:var(--pp-sidebar-hover,rgba(255,255,255,.08)); }
.pp-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--pp-topbar-accent, #3b82f6);
  color:var(--pp-primary-text,#fff); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .8rem; }
.pp-user-name { font-size: .875rem; }
.pp-user-menu { position: absolute; right: 0; top: calc(100% + 8px); min-width: 190px; background: var(--pp-surface, #fff);
  color: var(--pp-text, #0f172a); border-radius: 12px; box-shadow:var(--pp-shadow-md,0 12px 32px rgba(2,6,23,.18)); padding: .4rem; }
.pp-user-menu a { display: flex; align-items: center; gap: .55rem; padding: .55rem .7rem; border-radius: 8px;
  text-decoration: none; color: inherit; font-size: .875rem; }
.pp-user-menu a:hover { background: var(--pp-primary-soft, #eff6ff); color: var(--pp-primary, #2563eb); }

/* ── Sidebar ── */
.pp-sidebar { position: fixed; top: var(--pp-shell-topbar-h); bottom: 0; left: 0; width: var(--pp-shell-sidebar-w);
  z-index: 1020; display: flex; flex-direction: column; overflow-y: auto;
  background: var(--pp-sidebar-bg, #0f172a); color: var(--pp-sidebar-text, #cbd5e1);
  transition: transform .22s ease; scrollbar-width: thin; }
.pp-sidebar-nav { flex: 1; padding: .9rem .65rem; }
/* Compact: the logout row is a utility, not a nav item — it should not claim
   the same weight as the menu above it. */
.pp-sidebar-foot { padding: .3rem .45rem; border-top:1px solid var(--pp-sidebar-border,rgba(255,255,255,.08)); }
.pp-sidebar-foot .pp-menu-link { padding: .38rem .55rem; font-size: .8rem; gap: .5rem; border-radius: var(--pp-radius-sm, 8px); }
.pp-sidebar-foot .pp-menu-link i { font-size: .92rem; }
.pp-menu { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.pp-menu-link, .pp-menu-group summary { display: flex; align-items: center; gap: .65rem; padding: .6rem .75rem;
  border-radius: 9px; color: inherit; text-decoration: none; font-size: .9rem; cursor: pointer; }
.pp-menu-link i, .pp-menu-group summary i { font-size: 1.05rem; width: 20px; text-align: center; flex: 0 0 20px; }
.pp-menu-link:hover, .pp-menu-group summary:hover { background:var(--pp-topbar-hover,rgba(255,255,255,.07)); color: var(--pp-sidebar-active, #fff); }
.pp-menu-active { background: var(--pp-sidebar-active-bg,rgba(255,255,255,.1)); color: var(--pp-sidebar-active, #fff); font-weight: 600;
  box-shadow: inset 3px 0 0 var(--pp-sidebar-accent, #3b82f6); }
.pp-menu-group summary { list-style: none; }
.pp-menu-group summary::-webkit-details-marker { display: none; }
.pp-menu-caret { margin-left: auto; font-size: .7rem !important; transition: transform .15s; }
.pp-menu-group details[open] > summary .pp-menu-caret { transform: rotate(180deg); }
.pp-menu-sub { padding-left: 1.65rem; margin-top: 2px; }
/* ── Menu dividers (Menu Editor) — colour adapts to the sidebar text ── */
.pp-menu-divider { list-style: none; height: 0; margin: 8px .75rem; padding: 0; }
.pp-menu-divider-line { border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
.pp-menu-divider-dashed { border-top: 1px dashed color-mix(in srgb, currentColor 28%, transparent); }
.pp-menu-divider-dotted { border-top: 2px dotted color-mix(in srgb, currentColor 34%, transparent); }
.pp-menu-divider-gradient { height: 2px; border: 0; border-radius: 2px;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 30%, transparent) 25%, color-mix(in srgb, currentColor 30%, transparent) 75%, transparent); }
.pp-menu-divider-label { height: auto; border: 0; margin: .9rem .75rem .35rem;
  font-size: .68rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; opacity: .55; }

/* ── Main ── */
.pp-main { margin-left: var(--pp-shell-sidebar-w); padding-top: var(--pp-shell-topbar-h);
  min-height: 100vh; display: flex; flex-direction: column; }
.pp-content { flex: 1; padding: 1.5rem 1.75rem; }
/* Popup/modal mode (Add-in-popup): strip the app chrome + in-content page head
   (the modal header shows the title) + the copyright footer, so only the form
   fills the iframe. The form action bar becomes a sticky modal-style footer. */
body.pp-modal-mode .pp-topbar, body.pp-modal-mode .pp-sidebar, body.pp-modal-mode .pp-footer,
body.pp-modal-mode .pp-breadcrumb, body.pp-modal-mode .pp-page-head { display: none !important; }
body.pp-modal-mode .pp-main { margin-left: 0 !important; padding-top: 0 !important; min-height: 0; }
body.pp-modal-mode .pp-content { padding: 0 !important; max-width: none !important; }
body.pp-modal-mode { background: var(--pp-surface, #fff); }
body.pp-modal-mode form.ppg-form { padding: 1.25rem 1.4rem; }
/* form buttons pinned as a modal footer */
body.pp-modal-mode .pp-form-actions { position: sticky; bottom: 0; margin: 0; padding: .8rem 1.4rem;
  background: var(--pp-surface, #fff); border-top: 1px solid var(--pp-border, #e2e8f0);
  display: flex; justify-content: flex-end; gap: .6rem; flex-wrap: wrap; z-index: 5; }
body.pp-modal-mode .pp-form-actions .pp-required-hint { margin-right: auto; align-self: center; }
.pp-page-head { margin-bottom: 1.1rem; display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap; }
.pp-page-head h1 { margin: 0; font-size: 1.45rem; font-weight: 700; letter-spacing: -.01em; }
.pp-page-head .pp-breadcrumb { margin: 0; font-size: .85rem; }
.pp-footer { padding: .9rem 1.75rem; color: var(--pp-muted, #64748b); font-size: .8rem;
  border-top: 1px solid var(--pp-border, #e2e8f0); }
.pp-footer-left { text-align: left; }
.pp-footer-center { text-align: center; }
.pp-footer-right { text-align: right; }

/* breadcrumb */
.pp-breadcrumb { display: flex; align-items: center; gap: .45rem; font-size: .8rem; color: var(--pp-muted, #64748b); margin-bottom: .5rem; }
.pp-breadcrumb a { color: inherit; text-decoration: none; }
.pp-breadcrumb a:hover { color: var(--pp-primary, #2563eb); }
.pp-breadcrumb .sep { font-size: .65rem; }

/* grid variants (table-grid design props) */
.ppd-grid-table.pp-striped tbody tr:nth-child(odd) { background: var(--pp-surface-soft, #f8fafc); }
.ppd-grid-table.pp-hover tbody tr:hover { background: var(--pp-primary-soft, #eff6ff); }
.ppd-grid-table.pp-bordered td, .ppd-grid-table.pp-bordered th { border: 1px solid var(--pp-border, #e2e8f0); }
.ppd-grid-table.pp-compact td, .ppd-grid-table.pp-compact th { padding: 6px 10px; }
.ppd-grid-table.pp-comfortable td, .ppd-grid-table.pp-comfortable th { padding: 15px 12px; }

/* form layout (12-column grid, per-field responsive spans from the designer) */
.ppg-form-grid { display: grid; gap: 1rem 1.25rem; grid-template-columns: repeat(12, minmax(0, 1fr)); }
/* EVERY direct child of the form grid spans like a field: its --sp/--spt/--spm
   when set (fields, .pp-grid-item-wrapped components), else the full row. This
   also fixes UNWRAPPED components (span-12 nodes skip the .pp-grid-item wrapper
   — e.g. a full-width Cell Grid or heading), which previously collapsed to a
   single 1/12 track because only .pp-fld and > label carried a span rule. */
.ppg-form-grid > * { grid-column: span var(--sp, 12); min-width: 0; }
@media (max-width: 1023.98px) { .ppg-form-grid > * { grid-column: span var(--spt, var(--sp, 12)); } }
@media (max-width: 767.98px) { .ppg-form-grid > * { grid-column: span var(--spm, 12); } }
/* Spacing also publishes --pp-form-gap so nested zones — above all a Cell Grid
   holding the fields — inherit it unless they set their own Cell spacing. */
.ppg-form-grid { --pp-form-gap: 1rem; }
.ppg-form-grid.pp-gap-tight { gap: .5rem .75rem; --pp-form-gap: .5rem; }
.ppg-form-grid.pp-gap-compact { gap: .5rem .75rem; --pp-form-gap: .5rem; }
.ppg-form-grid.pp-gap-roomy { gap: 1.4rem 1.6rem; --pp-form-gap: 1.4rem; }
.ppg-form-grid > label { display: flex; flex-direction: column; gap: .3rem; font-size: .875rem; font-weight: 600;
  grid-column: span var(--sp, 12); min-width: 0; }
.ppg-form-grid.pp-label-left > label { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; }
/* Designed fields render as .pp-fld and are DESCENDANTS (usually nested in a
   Cell Grid), so Labels=left has to reach them through any container. Float
   labels sit inside the input and keep their own behaviour. */
.ppg-form-grid.pp-label-left .pp-fld:not(.is-float) { display: grid;
  grid-template-columns: var(--lbl-w, 160px) minmax(0, 1fr); align-items: center; gap: .25rem .9rem; }
.ppg-form-grid.pp-label-left .pp-fld:not(.is-float) > label { margin: 0; }
.ppg-form-grid.pp-label-left .pp-fld:not(.is-float) > .pp-fld-wrap { grid-column: 2; }
.ppg-form-grid.pp-label-left .pp-fld:not(.is-float) .pp-field-help { grid-column: 2; }
@media (max-width: 575.98px) {
  .ppg-form-grid.pp-label-left .pp-fld:not(.is-float) { grid-template-columns: 1fr; }
  .ppg-form-grid.pp-label-left .pp-fld:not(.is-float) > .pp-fld-wrap,
  .ppg-form-grid.pp-label-left .pp-fld:not(.is-float) .pp-field-help { grid-column: 1; }
}
.pp-field-help { color: var(--pp-muted, #64748b); font-weight: 400; font-size: .75rem; }
/* modern designed field (icon, focus ring, required mark, help, states) */
.pp-fld { grid-column: span var(--sp, 12); min-width: 0; display: flex; flex-direction: column; gap: var(--lbl-gap, .32rem); }
/* Bootstrap's reboot sets `label { margin-bottom: .5rem }`, which stacked on top
   of the flex gap and pushed every label ~8px away from its control. The gap
   alone owns that spacing (override it per field with Style → Label gap). */
.pp-fld > label { font-size: .8rem; font-weight: 650; color: var(--pp-text, #0f172a); display: flex; align-items: center; gap: .3rem; margin: 0; }
/* the required mark hugs its label text instead of claiming a flex slot */
.pp-fld .pp-req { color: var(--pp-danger, #dc2626); font-weight: 800; margin-left: -.12rem; min-width: 0; flex: 0 0 auto; }
.pp-fld-wrap { position: relative; display: flex; align-items: center; }
.pp-fld-wrap > i { position: absolute; left: .7rem; color: var(--pp-muted, #94a3b8); font-size: .9rem; pointer-events: none; }
.pp-fld-wrap > i ~ .pp-input { padding-left: 2.1rem; }
.pp-input { width: 100%; min-width: 0; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #dfe3ec); border-radius: var(--pp-radius-sm, 9px); padding: .5rem .7rem;
  font: inherit; font-size: .9rem; outline: none; transition: border-color .12s, box-shadow .12s; }
.pp-input:focus { border-color: var(--pp-primary, #2563eb); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary, #2563eb) 15%, transparent); }
.pp-input::placeholder { color: var(--pp-muted, #94a3b8); }
textarea.pp-input { min-height: 92px; resize: vertical; }
.pp-fld.pp-invalid .pp-input { border-color: var(--pp-danger, #dc2626); }
.pp-fld.pp-invalid .pp-input:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-danger, #dc2626) 20%, transparent); }
.pp-fld.pp-invalid > label { color: var(--pp-danger, #dc2626); }
/* Inline per-field validation message (reward early, punish late — set on blur) */
.pp-fld-err { display: flex; align-items: flex-start; gap: .32rem; margin-top: .35rem; font-size: .8rem; line-height: 1.35; color: var(--pp-danger, #dc2626); }
.pp-fld-err[hidden] { display: none; }
.pp-fld-err i { font-size: .85rem; line-height: 1.3; flex: 0 0 auto; margin-top: .02rem; }
.pp-checkline { display: flex; align-items: center; gap: .55rem; font-size: .9rem; font-weight: 600; padding: .45rem 0; cursor: pointer; }
.pp-checkline input { width: 17px; height: 17px; accent-color: var(--pp-primary, #2563eb); }
/* per-device spans */
@media (max-width: 1023.98px) { .pp-fld, .ppg-form-grid > label { grid-column: span var(--spt, var(--sp, 12)); } }
@media (max-width: 767.98px) { .pp-fld, .ppg-form-grid > label { grid-column: span var(--spm, 12); } }
/* per-device visibility (Designer "Show on" — breakpoints match the spans above) */
@media (min-width: 1024px) { .pp-hide-desktop { display: none !important; } }
@media (min-width: 768px) and (max-width: 1023.98px) { .pp-hide-tablet { display: none !important; } }
@media (max-width: 767.98px) { .pp-hide-mobile { display: none !important; } }
/* designed form sections + heading icons */
.pp-form-head { display: flex; align-items: center; gap: .55rem; margin: 0 0 1rem; }
.pp-form-head i:not(.pp-fold-caret) { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: .95rem;
  background: color-mix(in srgb, var(--pp-primary, #2563eb) 10%, var(--pp-surface, #fff)); color: var(--pp-primary, #2563eb); }
.pp-form-head h3 { margin: 0; font-size: 1.02rem; }
.pp-form-note { margin: -0.4rem 0 1rem; color: var(--pp-muted, #64748b); font-size: .84rem; }
/* action bar (optionally sticky) */
.pp-form-actions { display: flex; align-items: center; gap: .55rem; flex-wrap: wrap; margin-top: 1.1rem; }
.pp-form-actions.pp-sticky { position: sticky; bottom: 0; z-index: 5; margin: 1.1rem -0.5rem 0; padding: .7rem .5rem;
  background: color-mix(in srgb, var(--pp-bg, #f8fafc) 82%, transparent); backdrop-filter: blur(8px);
  border-top: 1px solid var(--pp-border, #e2e8f0); }
.pp-btn-form { display: inline-flex; align-items: center; gap: .45rem; border-radius: var(--pp-radius-sm, 9px);
  padding: .52rem 1rem; font-size: .875rem; font-weight: 650; cursor: pointer; text-decoration: none; line-height: 1.2;
  border: 1px solid var(--pp-border, #e2e8f0); background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  transition: border-color .12s, color .12s, background .12s, filter .12s; }
.pp-btn-form:hover { border-color: var(--pp-primary, #2563eb); color: var(--pp-primary, #2563eb); }
.pp-btn-form.primary { background: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb); color: var(--pp-primary-text, #fff); }
.pp-btn-form.primary:hover { filter: brightness(1.07); color: var(--pp-primary-text, #fff); }
.pp-btn-form.danger { color: var(--pp-danger, #dc2626); }
.pp-btn-form.danger:hover { border-color: var(--pp-danger, #dc2626); background: color-mix(in srgb, var(--pp-danger, #dc2626) 8%, transparent); }
/* ===== styled choice controls (Checkbox / RadioGroup / Switch plugins) ===== */
.pp-ctl { display: inline-flex; align-items: center; gap: .55rem; cursor: pointer; user-select: none;
  font-size: .9rem; font-weight: 600; color: var(--pp-text, #0f172a); position: relative; }
.pp-ctl input[type=checkbox], .pp-ctl input[type=radio] { position: absolute; opacity: 0; width: 1px; height: 1px; }
.pp-ctl.pp-disabled { opacity: .45; cursor: not-allowed; pointer-events: none; }
/* colors → --ctl accent */
.pp-c-primary { --ctl: var(--pp-primary, #2563eb); }
.pp-c-info { --ctl: #0ea5e9; }
.pp-c-success { --ctl: var(--pp-success, #16a34a); }
.pp-c-danger { --ctl: var(--pp-danger, #dc2626); }
.pp-c-secondary { --ctl: #8b5cf6; }
.pp-c-warning { --ctl: var(--pp-warning, #d97706); }
.pp-c-dark { --ctl: #334155; }
/* sizes */
.pp-sz-sm { --ctl-box: 16px; --sw-h: 20px; --sw-w: 36px; font-size: .8rem; }
.pp-sz-md { --ctl-box: 20px; --sw-h: 26px; --sw-w: 48px; }
.pp-sz-lg { --ctl-box: 25px; --sw-h: 32px; --sw-w: 62px; font-size: 1rem; }
/* checkbox */
.pp-checkbox .pp-ctl-box { width: var(--ctl-box); height: var(--ctl-box); border-radius: 27%;
  border: 1.5px solid var(--pp-border, #cbd5e1); background: var(--pp-surface, #fff);
  display: grid; place-items: center; transition: background .15s, border-color .15s; flex: 0 0 auto; }
.pp-checkbox .pp-ctl-box i { font-size: calc(var(--ctl-box) * .78); line-height: 1; color:var(--pp-primary-text,#fff);
  opacity: 0; transform: scale(.4); transition: opacity .12s, transform .15s; }
.pp-checkbox input:checked ~ .pp-ctl-box { background: var(--ctl); border-color: var(--ctl); }
.pp-checkbox input:checked ~ .pp-ctl-box i { opacity: 1; transform: none; }
.pp-checkbox input:focus-visible ~ .pp-ctl-box { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctl) 25%, transparent); }
/* radio */
.pp-ctl-group { display: flex; gap: 1.05rem; flex-wrap: wrap; align-items: center; }
.pp-ctl-group.pp-stack { flex-direction: column; align-items: flex-start; gap: .5rem; }
.pp-radio .pp-ctl-box { width: var(--ctl-box); height: var(--ctl-box); border-radius: 50%;
  border: 1.5px solid var(--pp-border, #cbd5e1); background: var(--pp-surface, #fff);
  display: grid; place-items: center; transition: background .15s, border-color .15s; flex: 0 0 auto; }
.pp-radio .pp-ctl-box::after { content: ""; width: 38%; height: 38%; border-radius: 50%; background:var(--pp-surface,#fff);
  transform: scale(0); transition: transform .15s; }
.pp-radio input:checked ~ .pp-ctl-box { background: var(--ctl); border-color: var(--ctl); }
.pp-radio input:checked ~ .pp-ctl-box::after { transform: none; }
.pp-radio input:focus-visible ~ .pp-ctl-box { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctl) 25%, transparent); }
/* switch (variants: classic / slim / text / icon / circle) */
.pp-switch .pp-sw { position: relative; width: var(--sw-w); height: var(--sw-h); border-radius: 999px;
  background: color-mix(in srgb, var(--pp-muted, #94a3b8) 38%, var(--pp-surface, #fff));
  transition: background .18s; flex: 0 0 auto; }
.pp-switch .pp-sw-thumb { position: absolute; top: 2px; left: 2px; width: calc(var(--sw-h) - 4px);
  height: calc(var(--sw-h) - 4px); border-radius: 50%; background:var(--pp-surface,#fff); display: grid; place-items: center;
  transition: left .18s; box-shadow:var(--pp-shadow-lg,0 1px 3px rgba(2, 6, 23, .35)); }
.pp-switch .pp-sw-thumb i { font-size: calc(var(--sw-h) * .48); line-height: 1; }
.pp-switch .pp-sw-thumb .ic-on { display: none; color: var(--ctl); }
.pp-switch .pp-sw-thumb .ic-off { display: block; color: var(--pp-muted, #94a3b8); }
.pp-switch input:checked ~ .pp-sw { background: var(--ctl); }
.pp-switch input:checked ~ .pp-sw .pp-sw-thumb { left: calc(100% - var(--sw-h) + 2px); }
.pp-switch input:checked ~ .pp-sw .ic-on { display: block; }
.pp-switch input:checked ~ .pp-sw .ic-off { display: none; }
.pp-switch input:focus-visible ~ .pp-sw { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctl) 25%, transparent); }
.pp-sw-on, .pp-sw-off, .pp-sw-onic, .pp-sw-offic { display: none; }
/* slim: thin track, overhanging thumb */
.pp-v-slim .pp-sw { height: calc(var(--sw-h) * .38); width: calc(var(--sw-w) * .88); }
.pp-v-slim .pp-sw-thumb { top: 50%; transform: translateY(-50%); left: 0;
  width: calc(var(--sw-h) * .85); height: calc(var(--sw-h) * .85); }
.pp-v-slim input:checked ~ .pp-sw .pp-sw-thumb { left: calc(100% - var(--sw-h) * .85); }
.pp-v-slim .pp-sw-thumb i { display: none !important; }
/* text: ON/OFF inside the track */
.pp-v-text .pp-sw-on, .pp-v-text .pp-sw-off { display: block; position: absolute; top: 50%; transform: translateY(-50%);
  font-size: calc(var(--sw-h) * .34); font-weight: 800; letter-spacing: .03em; transition: opacity .15s; }
.pp-v-text .pp-sw-on { left: 12%; color:var(--pp-primary-text,#fff); opacity: 0; }
.pp-v-text .pp-sw-off { right: 10%; color: var(--pp-muted, #64748b); opacity: 1; }
.pp-v-text input:checked ~ .pp-sw .pp-sw-on { opacity: 1; }
.pp-v-text input:checked ~ .pp-sw .pp-sw-off { opacity: 0; }
.pp-v-text .pp-sw-thumb i { display: none !important; }
/* icon: icons inside the track */
.pp-v-icon .pp-sw-onic, .pp-v-icon .pp-sw-offic { display: block; position: absolute; top: 50%; transform: translateY(-50%);
  font-size: calc(var(--sw-h) * .45); line-height: 1; transition: opacity .15s; }
.pp-v-icon .pp-sw-onic { left: 9%; color:var(--pp-primary-text,#fff); opacity: 0; }
.pp-v-icon .pp-sw-offic { right: 9%; color: var(--pp-muted, #64748b); opacity: 1; }
.pp-v-icon input:checked ~ .pp-sw .pp-sw-onic { opacity: 1; }
.pp-v-icon input:checked ~ .pp-sw .pp-sw-offic { opacity: 0; }
.pp-v-icon .pp-sw-thumb i { display: none !important; }
/* circle: round icon button */
.pp-v-circle .pp-sw { width: calc(var(--sw-h) * 1.4); height: calc(var(--sw-h) * 1.4); border-radius: 50%;
  display: grid; place-items: center; }
.pp-v-circle .pp-sw-thumb { position: static; width: auto; height: auto; background: transparent; box-shadow: none; transform: none; }
.pp-v-circle .pp-sw-thumb i { display: none; font-size: calc(var(--sw-h) * .62); color:var(--pp-primary-text,#fff); }
.pp-v-circle .pp-sw-thumb .ic-off { display: block; color: color-mix(in srgb, var(--pp-text, #0f172a) 55%, transparent); }
.pp-v-circle input:checked ~ .pp-sw .pp-sw-thumb .ic-on { display: block; color:var(--pp-primary-text,#fff); }
.pp-v-circle input:checked ~ .pp-sw .pp-sw-thumb .ic-off { display: none; }

/* guard styled controls against the generic form-section label/span rules
   (.ppg-form-section label span { min-width:120px; color:… } would stretch
   the radio dot / checkbox box / switch track into ellipses) */
.ppg-form-section label.pp-ctl span, label.pp-ctl span, .pp-ctl span { min-width: 0; }
.ppg-form-section .pp-ctl .pp-ctl-txt, .pp-ctl .pp-ctl-txt { color: inherit; font-weight: 600; font-size: inherit; }
label.pp-ctl { gap: .55rem; margin-bottom: 0; }
.pp-ctl { margin: 0; }

/* live state label beside a switch (sideText = state) */
.pp-sw-side { font-size: .85em; font-weight: 650; }
.pp-sw-side .on { display: none; color: var(--ctl); }
.pp-sw-side .off { display: inline; color: var(--pp-muted, #64748b); }
.pp-ctl input:checked ~ .pp-sw-side .on { display: inline; }
.pp-ctl input:checked ~ .pp-sw-side .off { display: none; }

/* ===== Buttons / Badges / Dropdowns (styled Button + Dropdown plugins) ===== */
/* color accent → --bc / --bcf(g). Brand-ish 4 map to theme tokens; rest fixed. */
.pp-bc-primary { --bc: var(--pp-primary, #4361ee); --bcf: #fff; }
.pp-bc-info { --bc: #0ea5e9; --bcf: #fff; }
.pp-bc-success { --bc: var(--pp-success, #16a34a); --bcf: #fff; }
.pp-bc-warning { --bc: var(--pp-warning, #e0a82e); --bcf: #1f2937; }
.pp-bc-danger { --bc: var(--pp-danger, #ef4444); --bcf: #fff; }
.pp-bc-secondary { --bc: #7c5cfc; --bcf: #fff; }
.pp-bc-dark { --bc: #3b3f5c; --bcf: #fff; }
/* base button */
.pp-b2 { display: inline-flex; align-items: center; justify-content: center; gap: .45rem;
  border: 1px solid transparent; border-radius: 8px; font: inherit; font-weight: 600; line-height: 1.2;
  cursor: pointer; text-decoration: none; white-space: nowrap; transition: filter .12s, background .12s, color .12s, border-color .12s;
  padding: .5rem .95rem; font-size: .875rem; background: var(--bc, #4361ee); color: var(--bcf, #fff); }
.pp-b2:hover { filter: brightness(1.08); }
.pp-b2:active { filter: brightness(.94); }
.pp-b2:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--bc) 30%, transparent); }
/* variants */
.pp-b2.is-light { background: color-mix(in srgb, var(--bc) 16%, var(--pp-surface, #fff)); color: var(--bc); }
.pp-b2.is-light:hover { background: color-mix(in srgb, var(--bc) 24%, var(--pp-surface, #fff)); filter: none; }
.pp-b2.is-outline { background: transparent; color: var(--bc); border-color: var(--bc); }
.pp-b2.is-outline:hover { background: var(--bc); color: var(--bcf); filter: none; }
/* sizes */
.pp-bz-sm { padding: .3rem .65rem; font-size: .78rem; border-radius: 7px; }
.pp-bz-lg { padding: .7rem 1.4rem; font-size: 1rem; border-radius: 10px; }
/* shapes */
.pp-b2.is-rounded { border-radius: 999px; }
.pp-b2.is-square { padding: .5rem; aspect-ratio: 1; }
.pp-b2.is-square.pp-bz-sm { padding: .32rem; }
.pp-b2.is-square.pp-bz-lg { padding: .72rem; }
.pp-b2.is-circle { border-radius: 50%; padding: .5rem; aspect-ratio: 1; }
.pp-b2.is-circle.pp-bz-sm { padding: .32rem; }
.pp-b2.is-circle.pp-bz-lg { padding: .72rem; }
.pp-b2.is-block { display: flex; width: 100%; }
.pp-b2.is-disabled, .pp-b2:disabled { opacity: .55; cursor: not-allowed; pointer-events: none; filter: none; }
.pp-b2 > i { font-size: 1.05em; line-height: 1; }
/* ---- badge (view display) ---- */
.pp-bdg { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid transparent;
  border-radius: 7px; padding: .18rem .55rem; font-size: .78rem; font-weight: 700; line-height: 1.35;
  background: var(--bc, #4361ee); color: var(--bcf, #fff); text-decoration: none; white-space: nowrap; }
.pp-bdg.is-light { background: color-mix(in srgb, var(--bc) 16%, var(--pp-surface, #fff)); color: var(--bc); }
.pp-bdg.is-outline { background: transparent; color: var(--bc); border-color: var(--bc); }
.pp-bdg.is-rounded { border-radius: 999px; }
.pp-bdg.is-dot::before { content: ""; width: .5em; height: .5em; border-radius: 50%; background: currentColor; }
.pp-bdg.pp-bz-sm { padding: .1rem .45rem; font-size: .7rem; }
.pp-bdg.pp-bz-lg { padding: .28rem .7rem; font-size: .9rem; }
a.pp-bdg:hover { filter: brightness(1.06); }
/* ---- dropdown edit control (native select styled as button) ---- */
.pp-dd { position: relative; display: inline-flex; align-items: center; max-width: 100%; }
.pp-dd.is-block { display: flex; width: 100%; }
.pp-dd select { appearance: none; -webkit-appearance: none; background: transparent; border: 0; color: inherit;
  font: inherit; font-weight: inherit; cursor: pointer; outline: none; padding: 0; margin: 0;
  padding-right: 1.4rem; width: 100%; text-overflow: ellipsis; }
.pp-dd select option { color:var(--pp-text,#1f2937); background:var(--pp-surface,#fff); }
.pp-dd > i { position: absolute; right: .6rem; pointer-events: none; font-size: .8em; opacity: .85; }
.pp-dd.pp-bz-lg > i { right: .9rem; }
/* segmented button toggle (edit) */
.pp-seg { display: inline-flex; flex-wrap: wrap; border-radius: 8px; overflow: hidden; gap: 0; }
.pp-seg.is-block { display: flex; width: 100%; }
.pp-seg .pp-seg-btn { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; flex: 0 0 auto;
  border: 1px solid var(--bc, #4361ee); background: transparent; color: var(--bc); font: inherit; font-weight: 600;
  padding: .45rem .9rem; font-size: .875rem; cursor: pointer; margin-left: -1px; transition: background .12s, color .12s; }
.pp-seg.is-block .pp-seg-btn { flex: 1; }
.pp-seg .pp-seg-btn:first-child { margin-left: 0; border-radius: 8px 0 0 8px; }
.pp-seg .pp-seg-btn:last-child { border-radius: 0 8px 8px 0; }
.pp-seg.is-rounded .pp-seg-btn:first-child { border-radius: 999px 0 0 999px; }
.pp-seg.is-rounded .pp-seg-btn:last-child { border-radius: 0 999px 999px 0; }
.pp-seg .pp-seg-btn.is-on { background: var(--bc); color: var(--bcf); }
.pp-seg.pp-bz-sm .pp-seg-btn { padding: .3rem .65rem; font-size: .78rem; }
.pp-seg.pp-bz-lg .pp-seg-btn { padding: .7rem 1.3rem; font-size: 1rem; }
.pp-seg.is-disabled { opacity: .55; pointer-events: none; }

/* ===== field label styles: default / inline / float / input-group icons ===== */
/* pre/post icons (input groups) */
.pp-fld-wrap { position: relative; display: flex; align-items: center; }
.pp-fld-ic { position: absolute; display: flex; align-items: center; justify-content: center;
  color: var(--pp-muted, #94a3b8); pointer-events: none; z-index: 2; font-size: .95rem; }
.pp-fld-ic.pre { left: .7rem; }
.pp-fld-ic.post { right: .7rem; }
.pp-fld-wrap.has-pre .pp-input { padding-left: 2.2rem; }
.pp-fld-wrap.has-post .pp-input { padding-right: 2.2rem; }
/* legacy single <i> children (plugin-emitted) keep working */
.pp-fld-wrap > i:first-child { position: absolute; left: .7rem; color: var(--pp-muted, #94a3b8); font-size: .9rem; pointer-events: none; z-index: 2; }
.pp-fld-wrap > i:first-child ~ .pp-input { padding-left: 2.1rem; }
/* inline: label beside input */
.pp-fld.is-inline { display: grid; grid-template-columns: var(--lbl-w, minmax(90px, 32%)) 1fr; align-items: center; column-gap: var(--lbl-gap, .9rem); }
.pp-fld.is-inline > label { margin: 0; }
.pp-fld.is-inline .pp-field-help { grid-column: 2; }
@media (max-width: 575.98px) { .pp-fld.is-inline { grid-template-columns: 1fr; } .pp-fld.is-inline .pp-field-help { grid-column: 1; } }
/* float label — label sits inside the input, floats up on focus / when filled */
.pp-fld.is-float .pp-fld-wrap { position: relative; }
.pp-fld.is-float .pp-fld-wrap > label { position: absolute; left: .75rem; top: 50%; transform: translateY(-50%);
  margin: 0; color: var(--pp-muted, #94a3b8); font-weight: 500; pointer-events: none;
  transition: transform .14s ease, font-size .14s ease, color .14s ease, top .14s ease; transform-origin: left center;
  max-width: calc(100% - 1.5rem); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* shrink-wrap the text: as a flex row it stretched, so the notch it cuts in
     the outline ran far past the label and the required mark sat way out. */
  display: inline-flex; align-items: baseline; gap: .12rem; width: max-content; }
.pp-fld.is-float .pp-fld-wrap > label .pp-req { margin: 0; font-size: .85em; }
.pp-fld.is-float .pp-fld-wrap.has-pre > label { left: 2.2rem; }
.pp-fld.is-float .pp-input { padding-top: .55rem; }
/* floated position (focus OR has content — placeholder=" " makes this work) */
.pp-fld.is-float .pp-input:focus ~ label,
.pp-fld.is-float .pp-input:not(:placeholder-shown) ~ label { color: var(--pp-primary, #2563eb); }
/* Outline variant: label notches the top border */
.pp-fld.is-float-outline .pp-input { border: 1px solid var(--pp-border, #dfe3ec); border-radius: 10px; background: var(--pp-surface, #fff); }
.pp-fld.is-float-outline .pp-input:focus ~ label,
.pp-fld.is-float-outline .pp-input:not(:placeholder-shown) ~ label {
  top: 0; transform: translateY(-50%) scale(.82); background: var(--pp-surface, #fff); padding: 0 .3rem; left: .55rem; }
.pp-fld.is-float-outline .pp-fld-wrap.has-pre .pp-input:focus ~ label,
.pp-fld.is-float-outline .pp-fld-wrap.has-pre .pp-input:not(:placeholder-shown) ~ label { left: .55rem; }
/* Filled variant: tinted box, label shrinks to the top inside, bottom accent on focus */
.pp-fld.is-float-filled .pp-input { border: 0; border-bottom: 2px solid var(--pp-border, #dfe3ec);
  border-radius: 8px 8px 0 0; background: color-mix(in srgb, var(--pp-text, #0f172a) 6%, var(--pp-surface, #fff));
  padding-top: 1.15rem; padding-bottom: .35rem; }
.pp-fld.is-float-filled .pp-input:focus { border-bottom-color: var(--pp-primary, #2563eb); box-shadow: none; }
.pp-fld.is-float-filled .pp-fld-wrap > label { top: 50%; }
.pp-fld.is-float-filled .pp-input:focus ~ label,
.pp-fld.is-float-filled .pp-input:not(:placeholder-shown) ~ label { top: .5rem; transform: translateY(-50%) scale(.78); }
.pp-fld.is-float textarea.pp-input ~ label { top: 1.1rem; transform: none; }
.pp-fld.is-float textarea.pp-input:focus ~ label,
.pp-fld.is-float textarea.pp-input:not(:placeholder-shown) ~ label { top: 0; }

/* input-group icons stay legible on any surface (incl. dark) */
.pp-fld-ic, .pp-fld-wrap > i:first-child { color: var(--pp-muted, #94a3b8); opacity: 1; }
/* flatpickr visible altInput inherits the themed input look */
input.flatpickr-input.pp-input, .pp-input.flatpickr-input { background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); }
.pp-fld-wrap .flatpickr-input.pp-input[readonly] { cursor: pointer; }
/* Foldable section (<details>) — a clean collapsible CARD. BOTH variants get a
   border + rounded corners + surface bg + padded body, mirroring the designer
   canvas (whose .ppd-c-section is always bordered). The "card" variant adds a
   drop shadow via .ppg-form-section; "plain" is the same frame WITHOUT the
   shadow. overflow-hidden clips a coloured header to the rounded top corners. */
details.pp-fold {
  padding: 0; overflow: hidden; margin-bottom: 1.25rem;
  border: 1px solid var(--pp-border, #e2e8f0);
  border-radius: var(--pp-radius, 12px);
  background: var(--pp-surface, #fff);
}
details.pp-fold.ppg-form-section { padding: 0; margin-bottom: 1.25rem; }   /* card = frame + shadow (from .ppg-form-section) */
/* summary = the header. NO padding here (hh-* presets own it — otherwise this
   rule's higher specificity would defeat them). Border separates head/body. */
.pp-fold > summary.pp-fold-summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: .55rem;
  margin: 0; user-select: none; border-bottom: 1px solid var(--pp-border, #e2e8f0); transition: filter .12s; }
.pp-fold:not([open]) > summary.pp-fold-summary { border-bottom: 0; }
.pp-fold > summary::-webkit-details-marker { display: none; }
.pp-fold > summary.pp-fold-summary h3 { margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-fold-caret { margin-left: auto; color: inherit; opacity: .55; font-size: .8rem; transition: transform .18s ease; flex: 0 0 auto; }
.pp-fold:not([open]) .pp-fold-caret { transform: rotate(-90deg); }
.pp-fold > summary.pp-fold-summary:hover { filter: brightness(.97); }
.pp-fold-body { padding: 1.15rem 1.25rem; }

/* top-level + section body 12-col grid — components with a width span sit side by side */
.pp-form-body { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem 1.25rem; align-items: start; }
.pp-form-body > * { grid-column: span var(--sp, 12); min-width: 0; }
@media (max-width: 1023.98px) { .pp-form-body > * { grid-column: span var(--spt, 12); } }
@media (max-width: 767.98px) { .pp-form-body > * { grid-column: span var(--spm, 12); } }
/* layout containers (Row / Columns / column grids) — mirror the designer canvas
   so multi-column arrangements render identically in the generated app */
.ppd-row, .ppd-lay { display: grid; gap: 1rem 1.25rem; align-items: start; }
.ppd-row.cols-1, .ppd-lay-1, .ppd-lay-col { grid-template-columns: 1fr; }
.ppd-row.cols-2, .ppd-lay-2 { grid-template-columns: 1fr 1fr; }
.ppd-row.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ppd-lay-kpi { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ppd-lay-analytics { grid-template-columns: 2fr 1fr; }
@media (max-width: 767.98px) { .ppd-row.cols-2, .ppd-row.cols-3, .ppd-lay-2, .ppd-lay-kpi, .ppd-lay-analytics { grid-template-columns: 1fr; } }
/* interactive tabs / accordion (component) — button controls reset to look like
   the canvas spans; app.js toggles [hidden]/.active */
button.ppd-tab { border: 0; background: transparent; cursor: pointer; font: inherit; }
.ppd-tabpane[hidden] { display: none; }
button.ppd-accordion-h { width: 100%; border: 0; cursor: pointer; font: inherit; text-align: left; }
.ppd-acc-caret { transition: transform .18s ease; }
.ppd-accordion-b[hidden] { display: none; }
/* section header height presets — compact scale */
.pp-form-head { align-items: center; }
.pp-form-head.hh-sm { padding-top: 1px; padding-bottom: 1px; margin-bottom: .55rem; }
.pp-form-head.hh-sm h3 { font-size: .82rem; }
.pp-form-head.hh-md { margin-bottom: .85rem; }
.pp-form-head.hh-md h3 { font-size: .98rem; }
.pp-form-head.hh-lg { padding-top: .3rem; padding-bottom: .3rem; margin-bottom: 1.05rem; }
.pp-form-head.hh-lg h3 { font-size: 1.15rem; }
.pp-fold-summary { align-items: center; gap: .5rem; }
/* Foldable section header — compact, mirrors the designer canvas. The section
   icon is a small INLINE glyph that inherits the header text colour (not the
   30px primary chip used by plain form-card headers, which looks bulky and
   clashes with a coloured header). h3 font-size is preserved per size. */
.pp-fold-summary > i:not(.pp-fold-caret) {
  width: auto; height: auto; background: none; border-radius: 0; color: inherit;
  display: inline-flex; align-items: center; place-items: unset; opacity: .85; flex: 0 0 auto;
}
.pp-fold-summary.hh-sm { padding: 3px 1.05rem; min-height: 30px; }
.pp-fold-summary.hh-sm h3 { font-size: .82rem; }
.pp-fold-summary.hh-sm > i:not(.pp-fold-caret) { font-size: .95rem; }
.pp-fold-summary.hh-md { padding: 7px 1.1rem; min-height: 40px; }
.pp-fold-summary.hh-md h3 { font-size: .98rem; }
.pp-fold-summary.hh-md > i:not(.pp-fold-caret) { font-size: 1.05rem; }
.pp-fold-summary.hh-lg { padding: 12px 1.2rem; min-height: 52px; }
.pp-fold-summary.hh-lg h3 { font-size: 1.15rem; }
.pp-fold-summary.hh-lg > i:not(.pp-fold-caret) { font-size: 1.25rem; }
/* A non-fold header with a custom background fills the section edge-to-edge.
   EXCLUDES the foldable <summary>: it lives in a padding-0 <details>, so the
   negative top margin would push it above the box and clip the text. */
.ppg-form-section > .pp-form-head[style*="background"]:not(.pp-fold-summary) { margin: -1.25rem -1.25rem .9rem; padding: .5rem 1.25rem; border-radius: var(--pp-radius, 12px) var(--pp-radius, 12px) 0 0; }
.ppg-form-section > .pp-form-head.hh-sm[style*="background"]:not(.pp-fold-summary) { padding: .3rem 1.25rem; }
.ppg-form-section > .pp-form-head.hh-lg[style*="background"]:not(.pp-fold-summary) { padding: .8rem 1.25rem; }
/* (foldable summary spans the full padding-0 <details>; overflow-hidden on the
   base details.pp-fold rule above clips a coloured summary to rounded corners) */
.pp-fold[open] > summary.pp-fold-summary { margin-bottom: 0; }
/* section content-area background (bodyBg) */
.pp-form-body[style*="--sec-body-bg"] { background: var(--sec-body-bg); border-radius: var(--pp-radius-sm, 8px); }
/* non-fold "plain" section = same bordered card as .ppg-form-section, minus the
   drop shadow (mirrors the canvas: every section is bordered, card adds shadow). */
.pp-section-plain { min-width: 0; margin-bottom: 1.5rem; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius, 12px); padding: 1.25rem; }

/* flatpickr calendar — token-driven so every theme + dark mode fits */
.flatpickr-calendar { background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: 14px;
  box-shadow:var(--pp-shadow-md,0 18px 50px rgba(2, 6, 23, .18)); font-family: inherit; }
.flatpickr-calendar.arrowTop::before, .flatpickr-calendar.arrowTop::after { border-bottom-color: var(--pp-surface, #fff); }
.flatpickr-calendar.arrowBottom::before, .flatpickr-calendar.arrowBottom::after { border-top-color: var(--pp-surface, #fff); }
.flatpickr-months .flatpickr-month, .flatpickr-current-month .flatpickr-monthDropdown-months,
.flatpickr-current-month input.cur-year { color: var(--pp-text, #0f172a); fill: var(--pp-text, #0f172a); }
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--pp-surface, #fff); }
.flatpickr-months .flatpickr-prev-month, .flatpickr-months .flatpickr-next-month { color: var(--pp-muted, #64748b); fill: var(--pp-muted, #64748b); }
.flatpickr-months .flatpickr-prev-month:hover svg, .flatpickr-months .flatpickr-next-month:hover svg { fill: var(--pp-primary, #2563eb); }
span.flatpickr-weekday { color: var(--pp-muted, #94a3b8); font-weight: 700; }
.flatpickr-day { color: var(--pp-text, #0f172a); border-radius: 9px; }
.flatpickr-day:hover, .flatpickr-day:focus { background: var(--pp-primary-soft, #eff6ff); border-color: transparent; }
.flatpickr-day.today { border-color: var(--pp-primary, #2563eb); }
.flatpickr-day.today:hover { background: var(--pp-primary-soft, #eff6ff); color: var(--pp-text, #0f172a); }
.flatpickr-day.selected, .flatpickr-day.selected:hover, .flatpickr-day.startRange, .flatpickr-day.endRange {
  background: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb); color: var(--pp-primary-text, #fff); }
.flatpickr-day.flatpickr-disabled, .flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: var(--pp-muted, #cbd5e1); }
.flatpickr-time { border-top: 1px solid var(--pp-border, #e2e8f0); }
.flatpickr-time input, .flatpickr-time .flatpickr-time-separator, .flatpickr-time .flatpickr-am-pm { color: var(--pp-text, #0f172a); }
.flatpickr-time input:hover, .flatpickr-time input:focus, .flatpickr-time .flatpickr-am-pm:hover { background: var(--pp-primary-soft, #eff6ff); }
.flatpickr-input[readonly] { cursor: pointer; }

/* client validation summary */
.pp-vsummary { display: none; margin-bottom: .9rem; padding: .65rem .9rem; border-radius: var(--pp-radius-sm, 9px);
  border: 1px solid color-mix(in srgb, var(--pp-danger, #dc2626) 40%, transparent);
  background: color-mix(in srgb, var(--pp-danger, #dc2626) 8%, var(--pp-surface, #fff)); font-size: .85rem; }
.pp-vsummary.show { display: block; }
.pp-vsummary strong { color: var(--pp-danger, #dc2626); display: flex; align-items: center; gap: .4rem; }
.pp-vsummary ul { margin: .3rem 0 0 1.4rem; padding: 0; }
.pp-vsummary a { color: var(--pp-danger, #dc2626); cursor: pointer; }
.pp-required-hint { color: var(--pp-muted, #64748b); font-size: .76rem; margin-left: auto; }
.pp-required-hint .pp-req { color: var(--pp-danger, #dc2626); font-weight: 800; }

/* grid extras */
.ppd-grid-table.pp-sticky thead th { position: sticky; top: 0; background: var(--pp-surface, #fff); z-index: 2; }
.ppd-grid-table.pp-nowrap td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }

/* designed list toolbar zones — one tidy, responsive row that wraps cleanly.
   Left = primary actions · Center = search (grows) · Right = utilities. */
/* List body is a 12-col grid so every top-level component honours the Designer's
   per-device Size & Layout span (--sp/--spt/--spm). The grid gap owns the vertical
   rhythm, so top-level children drop their own block margins to avoid doubling. */
.pp-list-zone { row-gap: .9rem; }
.pp-list-zone > * { margin-top: 0; margin-bottom: 0; }
.pp-toolbar { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
.pp-tb-zone { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; row-gap: .4rem; }
/* Primary + utility zones keep their natural width (never shrink); the center
   search zone takes the slack. Search + filter + view toggles group with the
   primary actions on the LEFT (justify:flex-start), and the utility zone is
   pinned to the far right via margin-left:auto — so on a wide screen the search
   sits beside the actions instead of floating in the middle of the toolbar. */
.pp-tb-left { flex: 0 0 auto; }
.pp-tb-center { flex: 1 1 auto; min-width: 0; justify-content: flex-start; }
.pp-tb-right { flex: 0 0 auto; margin-left: auto; justify-content: flex-end; }
/* Unified search field: leading magnifier (decorative) + input + a trailing
   accent SUBMIT button inside the field's right edge. A search-active state swaps
   the submit for a × clear. One self-contained control — no separate button. It
   grows to take slack but is CAPPED so it never balloons across a wide toolbar. */
.pp-tb-center form { flex: 1 1 auto; min-width: 0; max-width: 30rem; justify-content: flex-start; display: inline-flex; }
/* The BOX is the pill — the border/background/focus-ring live here, and the input
   is transparent+borderless inside it, so field + icon + submit read as ONE
   cohesive control (matches the Designer). Focus subtly expands the field. */
/* Frosted-glass search pill: a semi-transparent, backdrop-blurred surface with a
   refractive edge (light-catch border + inset top highlight) + soft shadow. All
   from --pp-* tokens so it adapts to light/dark/custom themes. */
.pp-search-box { position: relative; display: inline-flex; align-items: center; flex: 0 1 var(--pp-search-w, 280px); min-width: 150px; max-width: var(--pp-search-max, 320px); height: 34px; box-sizing: border-box; border-radius: 10px;
  border: 1px solid color-mix(in srgb, var(--pp-border, #cbd5e1) 78%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--pp-surface, #fff) 82%, transparent), color-mix(in srgb, var(--pp-surface, #fff) 60%, transparent));
  -webkit-backdrop-filter: blur(12px) saturate(150%); backdrop-filter: blur(12px) saturate(150%);
  box-shadow: 0 2px 8px rgba(2,6,23,.06), 0 1px 2px rgba(2,6,23,.04), inset 0 1px 0 color-mix(in srgb, #fff 55%, transparent);
  transition: border-color .16s, box-shadow .16s, background .16s, flex-basis .18s ease; }
.pp-search-box:focus-within { border-color: var(--pp-primary, #6366f1);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary, #6366f1) 15%, transparent), 0 2px 8px rgba(2,6,23,.06), inset 0 1px 0 color-mix(in srgb, #fff 60%, transparent);
  flex-basis: 320px; }
.pp-search-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--pp-muted, #94a3b8); font-size: .95rem; pointer-events: none; z-index: 1; }
.pp-search-box input[type="search"] { flex: 1 1 auto; width: 100%; min-width: 0; height: 100%; box-sizing: border-box; padding: 0 42px 0 34px; border: 0 !important; outline: 0 !important; background: transparent !important; box-shadow: none !important; border-radius: 10px; font-size: .875rem; color: var(--pp-text, #0f172a); }
.pp-search-box input[type="search"]::placeholder { color: var(--pp-muted, #94a3b8); opacity: 1; }
.pp-search-box input[type="search"]::-webkit-search-cancel-button,
.pp-search-box input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; display: none; }
/* trailing accent submit — a crisp rounded-square "go" with a soft primary shadow.
   Vertically centred with top:0/bottom:0 + margin:auto (NOT top:50%/translateY) so
   it stays dead-centre regardless of the field's exact height in any shell — the
   generated app's <button> reset shifts a translateY-centred button a couple px. */
.pp-search-go { position: absolute; right: 4px; top: 0; bottom: 0; margin: auto 0; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 27px; height: 27px; min-height: 0; padding: 0; border: 0; border-radius: 8px; cursor: pointer; line-height: 0; vertical-align: middle; background: var(--pp-primary, #6366f1); color: var(--pp-primary-text, #fff); box-shadow: 0 2px 5px color-mix(in srgb, var(--pp-primary, #6366f1) 42%, transparent); transition: background .15s, transform .15s, box-shadow .15s; }
.pp-search-go i { display: block; font-size: 15px; line-height: 1; }
.pp-search-go:hover { background: color-mix(in srgb, var(--pp-primary, #6366f1) 90%, #000); transform: scale(1.07); box-shadow: 0 3px 9px color-mix(in srgb, var(--pp-primary, #6366f1) 52%, transparent); }
.pp-search-go:active { transform: scale(.96); }
/* thin divider between the Filter button and the Table/Cards/Board view switch */
.pp-tb-viewsep { display: inline-block; width: 1px; height: 20px; margin: 0 .2rem; background: color-mix(in srgb, var(--pp-border, #cbd5e1) 80%, transparent); flex: 0 0 auto; }
/* the PDF button keeps its signature red glyph (color moved off the inline style
   so a custom icon override still works) */
.ppd-grid-toolbar-btn .bi-file-earmark-pdf-fill { color: var(--pp-fmt-pdf, #e2483d); }
/* × clear (only when a search is applied) — replaces the submit */
.pp-search-clear { display: none; position: absolute; right: 4px; top: 0; bottom: 0; margin: auto 0; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--pp-muted, #94a3b8); font-size: 12px; line-height: 1; cursor: pointer; text-decoration: none; }
.pp-search-clear:hover { background: color-mix(in srgb, var(--pp-danger, #dc2626) 12%, transparent); color: var(--pp-danger, #dc2626); }
.pp-search-box.is-clear-mode .pp-search-go { display: none; }
.pp-search-box.is-clear-mode .pp-search-clear { display: inline-flex; }
/* ── ACTIVE-SEARCH STATE ── When the list is filtered by a search the whole field
   turns an "alert" amber so it's unmistakable you're viewing a filtered subset,
   and the × clear pulses to invite clearing it back to the full list. */
.pp-search-form.is-searched .pp-search-box { border-color: var(--pp-warning, #f59e0b); background: color-mix(in srgb, var(--pp-warning, #f59e0b) 9%, var(--pp-surface, #fff)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-warning, #f59e0b) 22%, transparent); }
.pp-search-form.is-searched .pp-search-ico { color: var(--pp-warning, #f59e0b); }
.pp-search-form.is-searched .pp-search--minimal { background: color-mix(in srgb, var(--pp-warning, #f59e0b) 8%, transparent); border-bottom-color: var(--pp-warning, #f59e0b); box-shadow: none; }
.pp-search-box.is-clear-mode .pp-search-clear { color: var(--pp-danger, #dc2626); background: color-mix(in srgb, var(--pp-danger, #dc2626) 12%, transparent); animation: ppSearchClearPulse 1.5s ease-in-out infinite; }
.pp-search-box.is-clear-mode .pp-search-clear:hover { animation: none; }
@keyframes ppSearchClearPulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--pp-danger, #dc2626) 55%, transparent); }
  70%  { box-shadow: 0 0 0 7px color-mix(in srgb, var(--pp-danger, #dc2626) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--pp-danger, #dc2626) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) { .pp-search-box.is-clear-mode .pp-search-clear { animation: none; } }
/* ── Search field DESIGNS (chosen per list in the Designer: props.searchStyle) ──
   'glass' is the frosted base above. The rest re-skin the same pill structure. */
.pp-search--minimal { border: 0; border-bottom: 1.5px solid var(--pp-border, #cbd5e1); border-radius: 0; background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; }
.pp-search--minimal:focus-within { border-color: transparent; border-bottom-color: var(--pp-primary, #6366f1); background: transparent; box-shadow: none; }
.pp-search--minimal .pp-search-go { background: transparent; color: var(--pp-primary, #6366f1); }
.pp-search--minimal .pp-search-go:hover { background: color-mix(in srgb, var(--pp-primary, #6366f1) 12%, transparent); }
.pp-search--soft { border: 1px solid transparent; background: var(--pp-surface-soft, #f1f5f9); -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; }
.pp-search--soft:focus-within { border-color: var(--pp-primary, #6366f1); background: var(--pp-surface, #fff); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary, #6366f1) 13%, transparent); }
.pp-search--outline { border: 1px solid var(--pp-border, #cbd5e1); background: var(--pp-surface, #fff); -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; }
.pp-search--outline:focus-within { border-color: var(--pp-primary, #6366f1); background: var(--pp-surface, #fff); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary, #6366f1) 13%, transparent); }
.pp-search--pill { border-radius: 999px; }
.pp-search--pill .pp-search-go, .pp-search--pill .pp-search-clear { border-radius: 999px; }
/* compact record count: full sentence on desktop; an eye + short "n–n / total" on narrow */
.pp-record-info { color: var(--pp-muted, #64748b); font-size: .8rem; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.pp-ri-eye { display: none; font-size: .95rem; }
.pp-ri-short { display: none; font-variant-numeric: tabular-nums; }
.pp-tb-group { display: inline-flex; align-items: center; gap: .45rem; }
/* (Authoritative responsive toolbar rules live at the very end of this CSS so
   they win the cascade over the theme-bridge button restyle above.) */
/* ── Toolbar tooltips (data-pp-tip) — instant, styled, edge-aware ──
   Appears above the control; the [data-pp-tip-pos="left"/"right"] variants pin
   the bubble to that edge so buttons near the page border never clip off-screen. */
[data-pp-tip] { position: relative; }
[data-pp-tip]::after, [data-pp-tip]::before {
  opacity: 0; pointer-events: none; position: absolute; z-index: 60;
  transition: opacity .13s ease, transform .13s ease; transform: translateY(4px);
}
[data-pp-tip]::after {
  content: attr(data-pp-tip); left: 50%; bottom: calc(100% + 8px); margin-left: 0;
  transform: translateX(-50%) translateY(4px);
  background: var(--pp-tooltip-bg, #1e293b); color: var(--pp-tooltip-text, #fff); font-size: 12px; font-weight: 500; line-height: 1.3;
  white-space: nowrap; padding: 4px 8px; border-radius: 6px; box-shadow:var(--pp-shadow-md,0 6px 16px rgba(2,6,23,.22));
}
[data-pp-tip]::before {
  content: ''; left: 50%; bottom: calc(100% + 3px); transform: translateX(-50%) translateY(4px);
  border: 5px solid transparent; border-top-color: var(--pp-tooltip-bg, #1e293b);
}
[data-pp-tip]:hover::after, [data-pp-tip]:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }
[data-pp-tip]:hover::before, [data-pp-tip]:focus-visible::before { opacity: 1; transform: translateX(-50%) translateY(0); }
[data-pp-tip-pos="left"]::after { left: 0; transform: translateX(0) translateY(4px); }
[data-pp-tip-pos="left"]:hover::after, [data-pp-tip-pos="left"]:focus-visible::after { transform: translateX(0) translateY(0); }
[data-pp-tip-pos="right"]::after { left: auto; right: 0; transform: translateX(0) translateY(4px); }
[data-pp-tip-pos="right"]:hover::after, [data-pp-tip-pos="right"]:focus-visible::after { transform: translateX(0) translateY(0); }
/* Inside the grid the ::after bubble is clipped by .ppd-grid-table-wrap's
   overflow, so row actions use the body-level floating tip below instead. */
.ppd-grid-table [data-pp-tip]::after, .ppd-grid-table [data-pp-tip]::before { content: none; }
.pp-tipf { position: fixed; z-index: 2000; pointer-events: none; opacity: 0;
  background: var(--pp-tooltip-bg, #1e293b); color: var(--pp-tooltip-text, #fff);
  font-size: 12px; font-weight: 500; line-height: 1.3; white-space: nowrap;
  padding: 4px 8px; border-radius: 6px; box-shadow: var(--pp-shadow-md, 0 6px 16px rgba(2,6,23,.22));
  transform: translateY(4px); transition: opacity .12s ease, transform .12s ease; }
.pp-tipf.is-on { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .pp-tipf { transition: none; } }

/* the open dropdowns don't need a tooltip fighting the menu */
details.pp-cols-toggle[open] > summary[data-pp-tip]::after,
details.pp-cols-toggle[open] > summary[data-pp-tip]::before { display: none; }
.ppd-grid-foot { background: color-mix(in srgb, var(--pp-primary, #2563eb) 7%, var(--pp-surface, #fff));
  border-top: 2px solid color-mix(in srgb, var(--pp-primary, #2563eb) 35%, var(--pp-border, #e2e8f0));
  padding: 9px 12px; font-size: .84rem; }
.ppd-grid-foot small { color: color-mix(in srgb, var(--pp-primary, #2563eb) 65%, var(--pp-muted, #64748b));
  font-size: .74rem; font-weight: 700; }
.ppd-grid-foot strong { color: var(--pp-primary, #2563eb); font-size: .95rem; }
.pp-cols-toggle { position: relative; }
.pp-cols-toggle summary { list-style: none; display: inline-flex; cursor: pointer; }
.pp-cols-toggle summary::-webkit-details-marker { display: none; }
.pp-cols-menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 50; min-width: 180px;
  background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e2e8f0); border-radius: 10px;
  box-shadow:var(--pp-shadow-md,0 12px 32px rgba(2,6,23,.14)); padding: .5rem .7rem; display: flex; flex-direction: column; gap: .3rem; }
.pp-cols-menu label { display: flex; align-items: center; gap: .45rem; font-size: .85rem; cursor: pointer; }
.pp-cols-foot { margin-top: .35rem; padding-top: .4rem; border-top: 1px solid var(--pp-border, #e2e8f0); }
.pp-cols-copylink { width: 100%; display: flex; align-items: center; gap: .4rem; padding: .35rem .5rem; border: 0; border-radius: 6px; background: transparent; color: var(--pp-primary, #2563eb); font-size: .82rem; font-weight: 600; cursor: pointer; }
.pp-cols-copylink:hover { background: color-mix(in srgb, var(--pp-primary, #2563eb) 10%, transparent); }
.pp-selbar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.pp-sel-count { font-size: .85rem; font-weight: 600; color: var(--pp-primary, #2563eb); }
/* "Select all matching" banner inside the selection bar */
.pp-selall-banner { display: inline-flex; align-items: center; gap: .5rem; font-size: .82rem; }
.pp-selall-banner:not(:empty) { margin-left: .35rem; padding-left: .6rem; border-left: 1px solid var(--pp-border, #e2e8f0); }
.pp-selall-msg { color: var(--pp-muted, #64748b); display: inline-flex; align-items: center; gap: .3rem; }
.pp-selall-link { color: var(--pp-primary, #2563eb); font-weight: 700; text-decoration: underline; cursor: pointer; }
.pp-selall-link:hover { text-decoration: none; }
.pp-upd-panel { margin-bottom: .9rem; }

/* filter panel + chips */
.pp-filter-menu { min-width: 300px; max-height: 440px; overflow-y: auto; padding: .85rem .95rem;
  transform-origin: top center; animation: ppFilterIn .16s cubic-bezier(.2,.7,.3,1); }
@keyframes ppFilterIn { from { opacity: 0; transform: translateY(-7px) scale(.985); } to { opacity: 1; transform: none; } }
.pp-filter-title { display: flex; align-items: center; justify-content: center; gap: .4rem;
  font-size: .68rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
  color: var(--pp-muted, #64748b); padding: 0 0 .7rem; margin-bottom: .8rem; border-bottom: 1px solid var(--pp-border, #eef2f7); }
.pp-filter-menu form { display: flex; flex-direction: column; gap: .8rem; }
.pp-filter-menu label { display: flex; flex-direction: column; gap: .3rem; font-size: .76rem; font-weight: 700;
  color: var(--pp-text, #0f172a); }
.pp-filter-menu label > span:first-child { text-align: center; color: var(--pp-muted, #475569); }
.pp-filter-menu .pp-sf-trigger, .pp-filter-menu .ppd-grid-search,
.pp-filter-menu .pp-numrange, .pp-filter-menu .pp-daterange { width: 100%; box-sizing: border-box; }
.pp-filter-actions { display: flex; justify-content: center; gap: .6rem; margin-top: .5rem; padding-top: .85rem;
  border-top: 1px solid var(--pp-border, #eef2f7); }
.pp-filter-actions .pp-filter-apply, .pp-filter-actions .pp-filter-reset {
  display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 104px;
  padding: .5rem .9rem; font-size: .82rem; font-weight: 700; border-radius: var(--pp-radius-sm, 9px);
  cursor: pointer; text-decoration: none; transition: filter .12s, background .12s, border-color .12s, transform .06s; }
.pp-filter-actions .pp-filter-apply { background: var(--pp-primary, #2563eb); color: var(--pp-primary-text, #fff);
  border: 1px solid var(--pp-primary, #2563eb); box-shadow: 0 2px 8px color-mix(in srgb, var(--pp-primary, #2563eb) 32%, transparent); }
.pp-filter-actions .pp-filter-apply:hover { filter: brightness(1.06); }
.pp-filter-actions .pp-filter-apply:active { transform: translateY(1px); }
.pp-filter-actions .pp-filter-reset { background: var(--pp-surface, #fff); color: var(--pp-muted, #64748b);
  border: 1px solid var(--pp-border, #cbd5e1); }
.pp-filter-actions .pp-filter-reset:hover { border-color: var(--pp-primary, #2563eb); color: var(--pp-primary, #2563eb); }
/* Set Filter — shared popup for toolbar rows + column-header funnels (Excel/AG-Grid style) */
.pp-hf-btn { appearance: none; display: inline-flex; align-items: center; gap: 1px; vertical-align: middle;
  background: var(--pp-bg, #f8fafc); border: 1px solid var(--pp-border, #e2e8f0); cursor: pointer;
  padding: 2px 5px; margin-left: 6px; color: var(--pp-muted, #94a3b8); font-size: .68rem; line-height: 1;
  border-radius: 6px; transition: color .12s, background .12s, border-color .12s; }
.pp-hf-btn:hover { color: var(--pp-primary, #2563eb); background: var(--pp-primary-soft, #eff6ff); border-color: var(--pp-primary, #2563eb); }
.pp-hf-btn.is-active { color: var(--pp-primary-text, #fff); background: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb); }
.pp-hf-caret { font-size: .55rem; opacity: .75; }
.pp-hf-spin { display: none; width: .7rem; height: .7rem; border: 2px solid currentColor; border-right-color: transparent;
  border-radius: 50%; animation: ppHfSpin .6s linear infinite; }
.pp-hf-btn.is-loading .pp-hf-ic, .pp-hf-btn.is-loading .pp-hf-caret { display: none; }
.pp-hf-btn.is-loading .pp-hf-spin { display: inline-block; }
@keyframes ppHfSpin { to { transform: rotate(360deg); } }
.pp-sf-trigger { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem; width: 100%;
  padding: .4rem .6rem; border: 1px solid var(--pp-border, #cbd5e1); border-radius: var(--pp-radius-sm, 8px);
  background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); font-size: .8rem; cursor: pointer; text-align: left; }
.pp-sf-trigger:hover { border-color: var(--pp-primary, #2563eb); }
.pp-sf-trigger .pp-sf-tval { color: var(--pp-muted, #64748b); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-sf-trigger.is-active { border-color: var(--pp-primary, #2563eb); }
.pp-sf-trigger.is-active .pp-sf-tval { color: var(--pp-primary, #2563eb); font-weight: 600; }
.pp-setfilter-pop { position: absolute; z-index: 1200; min-width: 250px; max-width: 340px;
  background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius, 12px);
  box-shadow: 0 12px 34px rgba(2,6,23,.22); overflow: hidden; font-size: .82rem;
  animation: ppSfIn .12s ease-out; }
@keyframes ppSfIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.pp-sf-head { padding: .6rem .8rem; font-weight: 700; font-size: .8rem; border-bottom: 1px solid var(--pp-border, #eef2f7); }
.pp-sf-search { padding: .55rem .7rem 0; }
.pp-sf-search input { width: 100%; padding: .4rem .6rem; font-size: .82rem; color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #cbd5e1); border-radius: var(--pp-radius-sm, 8px); background: var(--pp-bg, #fff); }
.pp-sf-search input:focus { outline: none; border-color: var(--pp-primary, #2563eb); box-shadow: 0 0 0 3px var(--pp-primary-soft, #eff6ff); }
.pp-sf-all { display: flex; align-items: center; gap: .5rem; padding: .5rem .8rem .3rem; font-weight: 600; cursor: pointer; }
.pp-sf-list { max-height: 260px; overflow-y: auto; padding: .15rem .4rem .4rem; }
.pp-sf-opt { display: flex; align-items: center; gap: .55rem; padding: .32rem .4rem; border-radius: 7px; cursor: pointer; }
.pp-sf-opt:hover { background: var(--pp-primary-soft, #f1f5f9); }
.pp-sf-opt .pp-sf-lbl { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-sf-opt .pp-sf-cnt { flex: 0 0 auto; color: var(--pp-muted, #94a3b8); font-size: .72rem; font-variant-numeric: tabular-nums; }
.pp-sf-empty, .pp-sf-more { padding: .6rem .8rem; color: var(--pp-muted, #94a3b8); font-size: .76rem; text-align: center; }
.pp-sf-foot { display: flex; align-items: center; gap: .45rem; padding: .55rem .7rem; border-top: 1px solid var(--pp-border, #eef2f7); }
.pp-sf-foot .pp-sf-spacer { flex: 1 1 auto; }
.pp-sf-foot button { padding: .38rem .8rem; font-size: .78rem; font-weight: 600; border-radius: var(--pp-radius-sm, 8px);
  border: 1px solid var(--pp-border, #cbd5e1); background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); cursor: pointer; }
.pp-sf-foot button:hover { border-color: var(--pp-primary, #2563eb); }
.pp-sf-foot .pp-sf-clear { border-color: transparent; color: var(--pp-muted, #64748b); }
.pp-sf-foot .pp-sf-ok { background: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb); color: var(--pp-primary-text, #fff); }
.pp-setfilter-pop.pp-sf-sheet { position: fixed; left: 0; right: 0; bottom: 0; top: auto; width: 100%; max-width: 100%;
  border-radius: 16px 16px 0 0; animation: ppSfUp .16s ease-out; }
@keyframes ppSfUp { from { transform: translateY(100%); } to { transform: none; } }
.pp-setfilter-pop.pp-sf-sheet .pp-sf-list { max-height: 46vh; }
.pp-daterange, .pp-numrange { display: flex; align-items: center; gap: .4rem; }
.pp-daterange input, .pp-numrange input { flex: 1 1 0; min-width: 0; }
.pp-daterange-sep, .pp-numrange-sep { color: var(--pp-muted, #94a3b8); font-size: .8rem; flex: 0 0 auto; }
.pp-filter-chips { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .8rem;
  padding: .5rem .8rem; border: 1px dashed var(--pp-border, #cbd5e1); border-radius: var(--pp-radius-sm, 8px);
  color: var(--pp-muted, #64748b); font-size: .78rem; }
.pp-chips-label { font-weight: 800; text-transform: uppercase; letter-spacing: .05em; font-size: .68rem; }
.pp-chip { display: inline-flex; align-items: center; gap: .3rem; background: var(--pp-primary-soft, #dbeafe);
  color: var(--pp-primary, #2563eb); border-radius: 999px; padding: .2rem .65rem; text-decoration: none;
  font-size: .78rem; font-weight: 600; }
.pp-chip:hover { filter: brightness(.96); }
.pp-chip-clear { color: var(--pp-muted, #64748b); text-decoration: underline; font-weight: 600; }
.pp-export-menu a { display: flex; align-items: center; gap: .5rem; padding: .45rem .6rem; border-radius: 8px;
  text-decoration: none; color: var(--pp-text, #0f172a); font-size: .85rem; }
.pp-export-menu a:hover { background: var(--pp-primary-soft, #eff6ff); color: var(--pp-primary, #2563eb); }
.pp-page-buttons { display: flex; gap: .5rem; }

/* footer row under the grid: page buttons left, pagination right */
.pp-list-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  flex-wrap: wrap; margin-top: .9rem; }
.pp-lf-left, .pp-lf-right { display: flex; align-items: center; gap: .5rem; }
.pp-list-footer .pp-pagination { margin: 0; }
/* inline edit / inline add / spreadsheet (Pages module functionality) */
.pp-inline-input { width: 100%; min-width: 60px; background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-primary, #2563eb); border-radius: 7px; padding: .3rem .5rem; font-size: .875rem;
  outline: 2px solid var(--pp-primary-soft, #dbeafe); }
tr.pp-editing td, .pp-inline-add-row td { background: var(--pp-primary-soft, #eff6ff) !important; }
.pp-inline-save, .pp-inline-cancel, .pp-inline-save-new, .pp-inline-cancel-new {
  border: 1px solid var(--pp-border, #e2e8f0); background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border-radius: 7px; padding: .3rem .5rem; cursor: pointer; }
.pp-inline-save, .pp-inline-save-new { background: var(--pp-success, #16a34a); border-color: var(--pp-success, #16a34a); color:var(--pp-primary-text,#fff); }
.pp-inline-cancel:hover, .pp-inline-cancel-new:hover { color: var(--pp-danger, #dc2626); border-color: var(--pp-danger, #dc2626); }
.ppd-grid-table.pp-spreadsheet td[data-field] { cursor: cell; }
.ppd-grid-table.pp-spreadsheet td[data-field]:hover { box-shadow: inset 0 0 0 2px var(--pp-primary-soft, #dbeafe); }

.pp-loading { display: none; position: fixed; inset: 0; z-index: 1200; background:var(--pp-overlay,rgba(2,6,23,.35));
  align-items: center; justify-content: center; }
.pp-loading.show { display: flex; }
.pp-spinner { width: 44px; height: 44px; border-radius: 50%; border:4px solid var(--pp-topbar-scrollbar,rgba(255,255,255,.35));
  border-top-color: var(--pp-primary, #2563eb); animation: pp-spin .8s linear infinite; }
@keyframes pp-spin { to { transform: rotate(360deg); } }

/* ══ Token override layer ══
   designer-base.css hardcodes light colours; these overrides re-bind every
   generated component to the theme tokens so light AND dark ('is-dark' on
   <html>) both follow the Style designer. Loaded after designer-base.css. */
.ppd-shell-main, .pp-main { background: var(--pp-bg, #f8fafc); color: var(--pp-text, #0f172a); }
.ppd-grid-table-wrap { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e2e8f0);
  border-radius: var(--pp-radius, 12px); overflow: auto; box-shadow: var(--pp-shadow, none); }
.ppd-grid-table { width: 100%; border-collapse: collapse; color: var(--pp-text, #0f172a); background: var(--pp-grid-bg, var(--pp-surface, #fff)); }
.ppd-grid-table thead th { background: color-mix(in srgb, var(--pp-primary, #2563eb) 10%, var(--pp-surface, #fff));
  color: var(--pp-text, #0f172a); border-bottom: 2px solid color-mix(in srgb, var(--pp-primary, #2563eb) 35%, var(--pp-border, #e2e8f0));
  padding: 11px 12px; text-align: left; font-size: .82rem; font-weight: 700;
  text-transform: none; letter-spacing: normal; }
.ppd-grid-table thead th a { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: .25rem; }
.ppd-grid-table thead th a:hover { color: var(--pp-primary, #2563eb); }
.ppd-grid-table td { border-bottom: 1px solid var(--pp-border, #e2e8f0); padding: var(--pp-density-pad, 10px) 12px; font-size: .9rem; }
.ppd-grid-table.pp-striped tbody tr:nth-child(odd) { background: var(--pp-surface-soft, #f8fafc); }
.ppd-grid-table.pp-hover tbody tr:hover { background: var(--pp-primary-soft, #eff6ff); }
.ppd-grid-table.pp-sticky thead th { background: color-mix(in srgb, var(--pp-primary, #2563eb) 10%, var(--pp-surface, #fff)); }
.ppd-grid-toolbar { background: transparent; }
.ppd-grid-toolbar h3 { color: var(--pp-text, #0f172a); }
.ppd-grid-toolbar-btn { background: var(--pp-surface, #fff); color: var(--pp-text, #334155);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius-sm, 8px); padding: .375rem .75rem;
  font-size: .85rem; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: .35rem; }
.ppd-grid-toolbar-btn:hover { border-color: var(--pp-primary, #2563eb); color: var(--pp-primary, #2563eb);
  background: var(--pp-primary-soft, #eff6ff); }
.ppd-grid-search, select.ppd-grid-search, textarea.ppd-grid-search {
  background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius-sm, 8px); padding: .4rem .65rem; font-size: .875rem; }
.ppd-grid-search:focus { outline: 2px solid var(--pp-primary-soft, #dbeafe); border-color: var(--pp-primary, #2563eb); }
.ppd-grid-search::placeholder { color: var(--pp-muted, #94a3b8); }
.ppd-grid-pagination { display: flex; align-items: center; gap: .8rem; margin-top: .8rem;
  color: var(--pp-muted, #64748b); font-size: .85rem; }

/* numbered pagination (designer-style) */
.pp-pagination { display: flex; align-items: center; gap: .35rem; margin: .9rem 0 0; flex-wrap: wrap; }
.pp-pg { min-width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center;
  padding: 0 .55rem; border-radius: 10px; border: 1px solid var(--pp-border, #e2e8f0);
  background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a); text-decoration: none;
  font-size: .875rem; font-weight: 600; transition: border-color .12s, color .12s, background .12s; }
a.pp-pg:hover { border-color: var(--pp-primary, #2563eb); color: var(--pp-primary, #2563eb); }
.pp-pg.active { background: var(--pp-primary, #2563eb); border-color: var(--pp-primary, #2563eb);
  color: var(--pp-primary-text, #fff); }
.pp-pg.disabled { opacity: .45; }
.pp-pg.gap { border: 0; background: transparent; color: var(--pp-muted, #64748b); min-width: 24px; }
.pp-pg-summary { margin-left: .5rem; color: var(--pp-muted, #64748b); font-size: .8rem; }
.pp-pagination.pp-pag-center { justify-content: center; }
.pp-pagination.pp-pag-right { justify-content: flex-end; }
.pp-pagination.pp-pag-sm .pp-pg { min-width: 30px; height: 30px; font-size: .8rem; }
.pp-pagination.pp-pag-lg .pp-pg { min-width: 42px; height: 42px; font-size: .95rem; }
.ppd-grid-empty { color: var(--pp-muted, #64748b); text-align: center; padding: 1.4rem; }
.ppd-grid-bulk-actions { background: var(--pp-primary-soft, #eff6ff); border: 1px solid var(--pp-border, #e2e8f0);
  border-radius: var(--pp-radius-sm, 8px); padding: .5rem .8rem; margin-bottom: .8rem; }
.ppd-bulk-delete { background: var(--pp-danger, #dc2626); color:var(--pp-primary-text,#fff); border: 0;
  border-radius: var(--pp-radius-sm, 8px); padding: .4rem .8rem; cursor: pointer; }
.ppg-form-section, .ppd-dashboard-widget { background: var(--pp-surface, #fff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius, 12px); padding: 1.25rem;
  box-shadow: var(--pp-shadow, none); }
.ppg-form-heading { margin: 0 0 .9rem; font-size: 1.02rem; color: var(--pp-text, #0f172a); }
.ppd-auth-display { display: flex; justify-content: space-between; gap: 1rem; padding: .55rem 0;
  border-bottom: 1px solid var(--pp-border, #e2e8f0); font-size: .9rem; }
.ppd-auth-display span { color: var(--pp-muted, #64748b); }
.ppd-auth-action { background: var(--pp-primary, #6366f1); color: var(--pp-primary-text, #fff); border: 0;
  border-radius: var(--pp-radius-sm, 8px); padding: .55rem 1rem; cursor: pointer; text-decoration: none; }
.ppd-flash { background: var(--pp-primary-soft, #eff6ff); color: var(--pp-text, #0f172a);
  border: 1px solid var(--pp-border, #e2e8f0); border-radius: var(--pp-radius-sm, 8px);
  padding: .6rem .9rem; margin-bottom: .9rem; }
.ppd-dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.pp-cols-menu, .pp-user-menu { border: 1px solid var(--pp-border, #e2e8f0); }
.alert-danger { background: color-mix(in srgb, var(--pp-danger, #dc2626) 12%, var(--pp-surface, #fff));
  color: var(--pp-danger, #dc2626); border: 1px solid var(--pp-danger, #dc2626);
  border-radius: var(--pp-radius-sm, 8px); padding: .6rem .9rem; margin-bottom: .9rem; }

/* action buttons in grid rows */
/* ── Row actions: a collapsible pinned rail ──────────────────────────────────
   Reserving a column wide enough for every action steals space from real data
   on wide tables and is the reason buttons used to be clipped. Instead the
   column is a CONSTANT narrow rail — just a kebab — permanently docked to the
   right edge of the scroll container. The buttons live in an absolutely
   positioned flyout, so they occupy no column width and can never be clipped
   by it, however many actions or columns a table has.

   Revealed three ways so every input method is covered: hovering the row
   (pointer), focusing inside the cell (keyboard), and tapping the kebab
   (touch, where :hover does not exist). A sticky cell must be opaque or the
   row scrolls underneath it, so backgrounds are restated per row state. */
.ppd-grid-table th.ppd-grid-actions,
.ppd-grid-table td.ppd-grid-actions {
  position: sticky; right: 0; z-index: 2;
  /* One constant width in BOTH table-layout modes — nothing to measure, so
     nothing can mis-measure (icon fonts loading late used to under-size it). */
  width: 46px; min-width: 46px; max-width: 46px;
  padding: .35rem .4rem; text-align: center; white-space: nowrap;
  /* .pp-nowrap truncation puts overflow:hidden on every td, which would clip
     the flyout out of existence. This cell must let it escape. */
  overflow: visible;
  background: var(--pp-grid-bg, var(--pp-surface, #fff));
}
/* the header cell is sticky in BOTH axes, so it must outrank the sticky header */
.ppd-grid-table thead th.ppd-grid-actions { z-index: 5; font-size: 0; /* label would not fit the rail */
  background: color-mix(in srgb, var(--pp-primary, #2563eb) 10%, var(--pp-surface, #fff)); }
.ppd-grid-table thead th.ppd-grid-actions::after { content: "···"; font-size: 1rem; letter-spacing: .04em;
  color: var(--pp-muted, #64748b); }
/* row states — keep the pinned cell matching its row */
.ppd-grid-table.pp-striped tbody tr:nth-child(odd) td.ppd-grid-actions { background: var(--pp-surface-soft, #f8fafc); }
.ppd-grid-table.pp-hover tbody tr:hover td.ppd-grid-actions { background: var(--pp-primary-soft, #eff6ff); }
.ppd-grid-table tbody tr.pp-row-selected td.ppd-grid-actions,
.ppd-grid-table tbody tr.selected td.ppd-grid-actions { background: var(--pp-row-selected, var(--pp-primary-soft, #eff6ff)); }
.pp-inline-add-row td.ppd-grid-actions { background: var(--pp-surface-soft, #f8fafc); }
.ppd-grid-table tfoot td.ppd-grid-actions { background: var(--pp-surface-soft, #f8fafc); }
/* A soft edge appears only while the grid is actually scrolled sideways, so it
   reads as "there is more table over there" rather than a permanent border. */
.ppd-grid-table-wrap.pp-scrolled-x th.ppd-grid-actions,
.ppd-grid-table-wrap.pp-scrolled-x td.ppd-grid-actions {
  box-shadow: -8px 0 12px -8px rgba(2, 6, 23, .28); }

/* the rail + its trigger */
.pp-act-rail { position: relative; display: flex; align-items: center; justify-content: center; }
.pp-act-rail.pp-act-stack { flex-direction: column; gap: 2px; }
.pp-act-more { width: 30px; height: 30px; padding: 0; border-radius: 50%; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  background: transparent; border: 1px solid transparent; color: var(--pp-muted, #64748b);
  transition: background .16s ease, border-color .16s ease, color .16s ease, transform .16s ease; }
/* three dots drawn in CSS so no icon font can fail or resize us */
.pp-act-dots, .pp-act-dots::before, .pp-act-dots::after { width: 3px; height: 3px; border-radius: 50%;
  background: currentColor; display: block; }
.pp-act-dots { position: relative; }
.pp-act-dots::before, .pp-act-dots::after { content: ""; position: absolute; left: 0; }
.pp-act-dots::before { top: -5px; }
.pp-act-dots::after { top: 5px; }
.ppd-grid-table tbody tr:hover .pp-act-more,
td.ppd-grid-actions:focus-within .pp-act-more,
td.ppd-grid-actions.pp-act-open .pp-act-more { background: var(--pp-surface, #fff);
  border-color: var(--pp-border, #e2e8f0); color: var(--pp-text, #0f172a); }
.pp-act-more:focus-visible { outline: 2px solid var(--pp-primary, #2563eb); outline-offset: 2px; }
td.ppd-grid-actions.pp-act-open .pp-act-more { transform: rotate(90deg); }

/* the flyout — opens leftward over the row, costs the column no width */
.pp-act-pop { position: absolute; top: 50%; right: calc(100% + 4px); z-index: 6;
  display: flex; align-items: center; gap: 2px; padding: 4px 5px; white-space: nowrap;
  border-radius: 999px; background: var(--pp-surface, #fff);
  border: 1px solid var(--pp-border, #e2e8f0);
  box-shadow: 0 12px 28px -8px rgba(2, 6, 23, .3), 0 2px 6px -2px rgba(2, 6, 23, .18);
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translateY(-50%) translateX(6px) scale(.94); transform-origin: right center;
  transition: opacity .16s ease, transform .18s cubic-bezier(.2, .9, .3, 1.25), visibility .18s; }
.ppd-grid-table tbody tr:hover > td.ppd-grid-actions .pp-act-pop,
td.ppd-grid-actions:focus-within .pp-act-pop,
td.ppd-grid-actions.pp-act-open .pp-act-pop { opacity: 1; visibility: visible; pointer-events: auto;
  transform: translateY(-50%) translateX(0) scale(1); }
/* buttons fan in one after another — the reveal reads as deliberate, not a pop */
.pp-act-pop > * { opacity: 0; transform: translateX(8px); transition: opacity .18s ease, transform .18s ease; }
.ppd-grid-table tbody tr:hover > td.ppd-grid-actions .pp-act-pop > *,
td.ppd-grid-actions:focus-within .pp-act-pop > *,
td.ppd-grid-actions.pp-act-open .pp-act-pop > * { opacity: 1; transform: none; }
.pp-act-pop > *:nth-child(1) { transition-delay: .02s; }
.pp-act-pop > *:nth-child(2) { transition-delay: .05s; }
.pp-act-pop > *:nth-child(3) { transition-delay: .08s; }
.pp-act-pop > *:nth-child(4) { transition-delay: .11s; }
.pp-act-pop > *:nth-child(5) { transition-delay: .14s; }
.pp-act-pop > *:nth-child(6) { transition-delay: .17s; }

.ppd-grid-actions button, .ppd-grid-actions a { background: transparent; color: var(--pp-muted, #64748b);
  border: 1px solid transparent; border-radius: var(--pp-radius-sm, 7px); padding: .3rem .45rem;
  cursor: pointer; font-size: .85rem; display: inline-flex; align-items: center; gap: .3rem; text-decoration: none; }
.pp-act-pop button, .pp-act-pop a { width: 30px; height: 30px; padding: 0; border-radius: 50%; justify-content: center; }
.ppd-grid-actions button:hover, .ppd-grid-actions a:hover { color: var(--pp-primary, #2563eb);
  background: var(--pp-primary-soft, #eff6ff); border-color: var(--pp-border, #e2e8f0); }
.ppd-grid-actions .pp-act-danger:hover { color: var(--pp-danger, #dc2626);
  background: color-mix(in srgb, var(--pp-danger, #dc2626) 10%, transparent); }

/* Touch devices have no hover, so the kebab is always legible and the flyout
   is tap-driven. Bigger hit targets too — 44px is the accessible minimum. */
@media (hover: none), (max-width: 640px) {
  .pp-act-more { background: var(--pp-surface, #fff); border-color: var(--pp-border, #e2e8f0);
    color: var(--pp-text, #0f172a); width: 34px; height: 34px; }
  .pp-act-pop button, .pp-act-pop a { width: 38px; height: 38px; }
  .pp-act-pop { padding: 5px 6px; gap: 4px; }
}
@media (prefers-reduced-motion: reduce) {
  .pp-act-pop, .pp-act-pop > *, .pp-act-more { transition: none; }
  td.ppd-grid-actions.pp-act-open .pp-act-more { transform: none; }
}

/* ── Responsive ── */
.pp-nav-overlay { display: none; }
/* Global guard: nothing may stretch the page wider than the screen. Genuinely
   wide content (data grids, code blocks) scrolls inside its own container, so
   clipping the document horizontally never hides anything the user needs. */
html { overflow-x: hidden; }
.pp-shell, .pp-main, .pp-content { min-width: 0; max-width: 100%; }
/* Topbar: let the flex children shrink instead of pushing the bar past the
   viewport, and truncate a long brand name rather than overflowing. */
.pp-topbar { max-width: 100%; }
.pp-topbar > * { min-width: 0; }
.pp-brand { min-width: 0; overflow: hidden; }
.pp-brand-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 991.98px) {
  .pp-burger { display: block; }
  .pp-sidebar { transform: translateX(-100%); }
  body.pp-nav-open .pp-sidebar { transform: none; }
  body.pp-nav-open .pp-nav-overlay { display: block; position: fixed; inset: var(--pp-shell-topbar-h) 0 0 0;
    background:var(--pp-scrim,rgba(2,6,23,.45)); z-index: 1015; }
  .pp-main { margin-left: 0; }
  .pp-user-name { display: none; }
  .pp-content { padding: 1rem; }
}
/* Phones: a topbar dropdown can't be a fixed-width box anchored to a trigger
   near the edge — it spills off-screen (the 340px notification panel is
   right-anchored to a bell that is NOT the rightmost item, so its left edge ran
   past the viewport = the "not aligned within the screen" report). Pin every
   topbar dropdown to the viewport so it always sits fully within the screen. */
@media (max-width: 575.98px) {
  .pp-lang .pp-user-menu, .pp-notif .pp-user-menu,
  .pp-theme .pp-user-menu, .pp-user .pp-user-menu, .pp-quick {
    position: fixed; left: 8px; right: 8px; top: calc(var(--pp-shell-topbar-h) + 6px);
    width: auto; min-width: 0; max-width: none;
  }
  .pp-user-menu.pp-notif-menu { min-width: 0; max-width: none; }
  .pp-notif-list { max-height: calc(100vh - var(--pp-shell-topbar-h) - 72px); }
  /* tighter gutters + bar so a narrow screen is used fully without overflowing */
  .pp-topbar { gap: .4rem; padding: 0 .7rem; }
  .pp-content { padding: 1rem .85rem; }
  .pp-footer { padding: .85rem 1rem; }
  .pp-page-head h1 { font-size: 1.25rem; }
}

/* ── ComponentRenderer widgets (dashboard + view/list components) ──
   designer-base.css ships these with the Designer's fixed light "canvas paper"
   palette (hardcoded #fff / slate hexes). Re-bind their surfaces and text to
   the app theme tokens so they follow light AND dark. Every fallback repeats
   the Designer's original literal, so the light theme stays pixel-identical.
   (Most --ppd-* tokens are already emitted for both modes by the theme
   compiler; only --ppd-shadow-sm / --ppd-status-h are referenced but never
   defined outside the Designer, so they are supplied here.) */
:root {
  --ppd-shadow-sm: var(--pp-shadow-sm, 0 1px 2px rgba(15, 23, 42, .05));
  --ppd-status-h: var(--pp-text, #0f172a);
}
/* card / paper surfaces. NOTE: .ppd-c-image is intentionally NOT here — the
   Image component's box (.ppd-c-imgbox) defaults to TRANSPARENT so SVG/PNG logos
   show through; a paper fill here would re-add the white box (only the empty-state
   .ppd-c-image.placeholder keeps a subtle fill, styled in designer.css). */
.ppd-widget, .ppd-stat, .ppd-c-generic, .ppd-media, .ppd-map,
.ppd-qr, .ppd-signature, .ppd-toast, .ppd-modal-box, .ppd-offcanvas,
.ppd-masterdetail, .ppd-filterpanel, .ppd-advsearch, .ppd-c-search,
.ppd-chip, .ppd-linkchip, .ppd-cal-day, .ppd-kanban-card, .ppd-tabbar,
.ppd-tab.active, .ppd-mapper-row select {
  background: var(--pp-surface, #fff);
}
/* subtle fills, header strips, tracks */
.ppd-c-viewval, .ppd-step, .ppd-c-section > .h, .ppd-mapper-row.head,
.ppd-modal-head, .ppd-offcanvas-h, .ppd-kanban-col, .ppd-masterdetail-list,
.ppd-progress, .ppd-c-file, .ppd-quicklink {
  background: var(--pp-surface-soft, #f8fafc);
}
/* primary / body text on widgets */
.ppd-widget-h, .ppd-c-text, .ppd-quicklink, .ppd-kpi-value,
.ppd-c-field > label, .ppd-c-checkline, .ppd-tl-row strong, .ppd-total strong,
.ppd-md-row, .ppd-feed-row .k, .ppd-mapper-src, .ppd-filterpanel-h,
.ppd-c-generic-meta strong, .ppd-c-viewval, .ppd-kanban-card {
  color: var(--pp-text, #0f172a);
}
/* muted / secondary text + icons */
.ppd-c-inputwrap > i, .ppd-mapper-row > i, .ppd-md-row > i,
.ppd-media > span:first-child, .ppd-progress-lbl, .ppd-c-image.placeholder,
.ppd-filterpanel-row, .ppd-step, .ppd-c-file {
  color: var(--pp-muted, #64748b);
}
.ppd-quicklink:hover { border-color: var(--pp-primary, #2563eb); color: var(--pp-primary, #2563eb); }

/* ═══ AUTHORITATIVE RESPONSIVE LIST TOOLBAR (last = wins the cascade) ═══
   Desktop (>992px) = ALWAYS one tidy row. The auto-fit JS keeps it single-row by,
   in order: (1) moving the lowest-priority actions (data-pp-ov: refresh, density,
   print, pdf) into the "⋯ More" menu — primary labels + the record count stay
   visible; (2) hiding the record count; (3) collapsing the remaining secondary
   labels (.pp-tb-collapsible + .pp-tb-txt) to icons. Tablet + mobile (≤992px) =
   two rows: row 1 = primary actions + search, row 2 = view switch + actions. */
.pp-toolbar, .ppd-grid-toolbar { gap: .4rem; align-items: center; flex-wrap: wrap; row-gap: .5rem; }
.ppd-grid-toolbar-btn { padding: 0 .6rem; height: 34px; gap: .35rem; font-size: .82rem; white-space: nowrap; flex: 0 0 auto; }
.ppd-grid-toolbar-btn[data-pp-tip]:not(.pp-tb-collapsible) { padding: 0; width: 34px; justify-content: center; gap: 0; }
.ppd-grid-toolbar-btn.pp-tb-collapsible { width: auto; padding: 0 .6rem; justify-content: center; gap: .35rem; }
.ppd-grid-search { height: 34px; }
.pp-tb-zone { flex-wrap: wrap; gap: .4rem; min-width: 0; }
.pp-tb-left { flex: 0 1 auto; }
.pp-tb-center { flex: 1 1 auto; min-width: 0; justify-content: center; }
.pp-tb-center form { flex: 0 1 auto; }
.pp-search-box { flex: 0 1 220px; min-width: 130px; max-width: 240px; }
.pp-tb-right { flex: 0 0 auto; margin-left: auto; justify-content: flex-end; }
.pp-tb-txt { display: inline; }
/* progressive collapse stages (added by the auto-fit JS) */
.pp-tb-hideinfo .pp-record-info { display: none; }
.pp-tb-compact .pp-tb-collapsible .pp-tb-txt { display: none; }
.pp-tb-compact .pp-tb-collapsible { padding: 0; width: 34px; justify-content: center; gap: 0; }
.pp-tb-compact .pp-tb-collapsible .bi-chevron-down { display: none; }
.pp-tb-compact .pp-search-box { max-width: 170px; }
/* ── "⋯ More" overflow menu ── */
.pp-tb-more { position: relative; }
.pp-tb-more[hidden] { display: none; }
.pp-tb-more > summary { list-style: none; }
.pp-tb-more > summary::-webkit-details-marker { display: none; }
.pp-more-menu { position: absolute; top: calc(100% + 6px); right: 0; left: auto; z-index: 60; display: flex; flex-direction: column; gap: 2px; min-width: 188px; padding: 6px; background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e2e8f0); border-radius: 12px; box-shadow: var(--pp-shadow-lg, 0 14px 34px rgba(2,6,23,.18)); }
.pp-tb-more.pp-anchor-r .pp-more-menu { right: auto; left: 0; }
.pp-more-menu > * { width: 100% !important; height: 36px !important; justify-content: flex-start !important; border: 0 !important; border-radius: 8px !important; padding: 0 10px !important; gap: .6rem !important; box-shadow: none !important; background: transparent !important; color: var(--pp-text, #334155) !important; }
.pp-more-menu > *:hover { background: var(--pp-surface-soft, #f1f5f9) !important; color: var(--pp-primary, #6366f1) !important; }
.pp-more-menu .pp-tb-txt, .pp-more-lbl { display: inline !important; font-size: .85rem; font-weight: 500; }
.pp-more-menu [data-pp-tip]::after, .pp-more-menu [data-pp-tip]::before { content: none !important; }
/* dropdowns must stay on-screen: default opens to the RIGHT (left:0); only menus
   flagged .pp-anchor-r (near the right edge) open to the left. Capped to viewport. */
.pp-cols-menu { right: auto; left: 0; max-width: min(320px, calc(100vw - 24px)); }
.pp-cols-toggle.pp-anchor-r .pp-cols-menu { left: auto; right: 0; }
/* ── Tablet + Mobile: exactly TWO rows ──
   Row 1 = primary actions + search (labels kept). Row 2 = view switch + secondary
   ACTIONS, kept icon-only + count hidden so the action cluster stays a single line
   (never a third row). The record count still shows in the grid's pagination. */
@media (max-width: 992px) {
  .pp-tb-more { display: none !important; }
  /* basis:0 (not auto) keeps the search on row 1 with the primary actions instead
     of wrapping to its own line — so it stays exactly two rows, never three. */
  .pp-tb-left { flex: 0 0 auto; order: 0; }
  .pp-tb-center { order: 1; flex: 1 1 0; min-width: 140px; justify-content: flex-start; }
  .pp-tb-center form { flex: 1 1 0; min-width: 0; }
  .pp-search-box { flex: 1 1 0; min-width: 120px; max-width: none; }
  /* row 2 (the action cluster) is JUSTIFIED so the icons spread evenly across the
     full width — a tidy, professional two-row toolbar on tablet + mobile. */
  .pp-tb-right { order: 2; flex: 1 1 100%; margin-left: 0; justify-content: space-between; }
  /* the row-2 actions collapse to icons, and the record count becomes a compact
     "👁 n–n / total" (there's spare room on the second row on tablet/mobile). */
  .pp-tb-right .pp-record-info { display: inline-flex; }
  .pp-tb-right .pp-ri-full { display: none; }
  .pp-tb-right .pp-ri-eye, .pp-tb-right .pp-ri-short { display: inline; }
  .pp-tb-right .pp-tb-collapsible .pp-tb-txt { display: none; }
  .pp-tb-right .pp-tb-collapsible { padding: 0; width: 34px; justify-content: center; gap: 0; }
  .pp-tb-right .pp-tb-collapsible .bi-chevron-down { display: none; }
}
/* Large phones: also collapse the row-1 secondary labels (Views / Inline Add /
   Filter) to icons so the primary row + search never spill to a third line. The
   primary Add and the search input keep their text. */
@media (max-width: 640px) {
  .pp-tb-collapsible:not(.pp-tb-primary):not(.pp-search-submit) .pp-tb-txt { display: none; }
  .pp-tb-collapsible:not(.pp-tb-primary):not(.pp-search-submit) { padding: 0; width: 34px; justify-content: center; gap: 0; }
  .pp-tb-collapsible .bi-chevron-down { display: none; }
}
@media (max-width: 480px) {
  .pp-search-submit .pp-tb-txt { display: none; }
  .pp-search-submit { padding: 0; width: 34px; justify-content: center; gap: 0; }
  .pp-tb-right { justify-content: space-between; }
  .pp-tb-right .pp-tb-group { gap: .3rem; }
}
CSS;
    }

    /**
     * Compile the project's active theme (Style designer) into
     * assets/css/theme.css so the generated app matches the designed look.
     */
    /**
     * Per-page chrome from the Designer page Properties panel (Layout + Advanced):
     * a per-page THEME override, content width / alignment / device cap, plus the
     * page's Custom CSS and Custom JS. Returns raw css + js so each page generator
     * can fold the css into its <style> and drop the js as a <script> before body.
     *
     * @param array<string, mixed> $design  A resolved page design (has layout + settings)
     * @return array{css: string, js: string}
     */
    private function pageChrome(array $design): array
    {
        $layout   = (array) ($design['layout'] ?? []);
        $settings = (array) ($design['settings'] ?? []);
        $slug = (string) ($this->ctx['slug'] ?? '');
        $css = '';

        // 1) Per-page THEME override — compile the chosen theme's tokens inline so
        //    they win over the default theme.css for this page only.
        if ($this->defaultThemeId === null) {
            $this->defaultThemeId = ThemeRegistry::activeDefault($slug) ?: 'default';
        }
        // 'default' (and empty) mean "inherit the project default theme" — no override.
        $themeId = trim((string) ($layout['theme'] ?? ''));
        if ($themeId !== '' && $themeId !== 'default' && $themeId !== $this->defaultThemeId) {
            $theme = ThemeRegistry::resolvedForBuild($slug, $themeId);
            if ($theme !== null) {
                $css .= "/* page theme override: {$themeId} */\n" . ThemeCompiler::css($theme, ':root') . "\n";
                $tc = trim((string) ($theme['css'] ?? ''));
                if ($tc !== '') $css .= $tc . "\n";
            }
        }

        // 2) Content width / alignment / device cap → constrain .pp-content.
        //    fluid = full width (default); fixed = centred max-width; full = edge
        //    to edge (drop side padding); device caps the max-width.
        $width  = (string) ($layout['width'] ?? 'fluid');
        $align  = (string) ($layout['align'] ?? 'center');
        $device = (string) ($layout['device'] ?? 'desktop');
        $max = $width === 'fixed' ? 1080 : null;
        $cap = $device === 'mobile' ? 620 : ($device === 'tablet' ? 1024 : null);
        if ($cap !== null) $max = $max === null ? $cap : min($max, $cap);
        $rules = '';
        if ($max !== null) {
            $m = $align === 'left' ? '0 auto 0 0' : ($align === 'right' ? '0 0 0 auto' : '0 auto');
            $rules .= "max-width:{$max}px;margin:{$m};width:100%;";
        }
        if ($width === 'full') $rules .= "padding-left:0;padding-right:0;max-width:none;";
        if ($rules !== '') $css .= ".pp-content{{$rules}}\n";

        // 3) Custom CSS
        $custom = trim((string) ($settings['customCss'] ?? ''));
        if ($custom !== '') $css .= "/* page custom css */\n" . $custom . "\n";

        return ['css' => $css, 'js' => trim((string) ($settings['customJs'] ?? ''))];
    }

    /** pageChrome() as ready-to-inject HTML (a <style> + <script>), or '' if empty. */
    private function pageChromeHtml(array $design): string
    {
        $c = $this->pageChrome($design);
        return ($c['css'] !== '' ? "<style>\n" . $c['css'] . "</style>\n" : '')
            . ($c['js'] !== '' ? "<script>\n" . $c['js'] . "\n</script>\n" : '');
    }

    public function generateThemeCss(string $outputDir, string $slug): array
    {
        $this->log('Compiling active theme…');
        $cssDir = $outputDir . '/assets/css';
        if (!is_dir($cssDir)) mkdir($cssDir, 0755, true);

        $themeId = ThemeRegistry::activeDefault($slug);
        $theme = ThemeRegistry::resolvedForBuild($slug, $themeId) ?? ThemeRegistry::resolvedForBuild($slug, 'default');

        $css = "/* Compiled from the PPphp Style designer — regenerated on every build. */\n";
        if ($theme !== null) {
            $css .= ThemeCompiler::css($theme, ':root') . "\n";
            $custom = trim((string) ($theme['css'] ?? ''));
            if ($custom !== '') {
                $css .= "\n/* Theme custom CSS */\n" . $custom . "\n";
            }
            $this->log('Compiled theme: ' . (string) ($theme['label'] ?? $themeId));
        } else {
            $this->log('No active theme resolved — wrote empty theme.css');
        }

        return [$this->writeFile($cssDir . '/theme.css', $css)];
    }

    /**
     * Export the Events designer state into the generated application:
     * core/Events.php (dispatcher), app/hooks.php (PHP hook closures),
     * app/functions.php (custom PHP), assets/js/events.js (client events),
     * and cron.php (scheduled task runner).
     */
    /**
     * Merge compiled Notification Rules (from the Notifications GUI) into the
     * events state: each rule becomes PHP appended to the target table's
     * afterAddSave / afterEditSave / afterDelete hook, calling the Notification
     * API helpers. Coexists with any hand-written event code (appended after it).
     */
    private function mergeNotifRules(array $eventsState): array
    {
        $rules = (array) ($this->ctx['notifRules'] ?? []);
        if ($rules === []) return $eventsState;

        $notifTable = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($this->ctx['advanced']['notifTableName'] ?? 'notifications')) ?: 'notifications';
        $eventFor = ['add' => 'afterAddSave', 'edit' => 'afterEditSave', 'delete' => 'afterDelete'];

        foreach ($rules as $rule) {
            if (!is_array($rule) || ($rule['enabled'] ?? true) === false) continue;
            $table = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($rule['table'] ?? ''));
            $trigger = (string) ($rule['trigger'] ?? 'add');
            if ($table === '' || !isset($eventFor[$trigger])) continue;

            $snippet = $this->compileNotifRule($rule, $table, $trigger, $notifTable);
            if ($snippet === '') continue;

            $event = $eventFor[$trigger];
            $existing = (string) ($eventsState['tables'][$table]['page'][$event]['code'] ?? '');
            $eventsState['tables'][$table]['page'][$event] = [
                'enabled' => true,
                'code'    => ($existing !== '' ? $existing . "\n\n" : '') . $snippet,
            ];
        }
        return $eventsState;
    }

    /** Compile one rule to a PHP snippet that runs inside the event hook closure. */
    private function compileNotifRule(array $rule, string $table, string $trigger, string $notifTable): string
    {
        $types  = ['info', 'success', 'warning', 'error'];
        $ntype  = in_array(($rule['ntype'] ?? 'info'), $types, true) ? (string) $rule['ntype'] : 'info';
        $title  = (string) ($rule['title'] ?? '');
        $msg    = (string) ($rule['message'] ?? '');
        if ($msg === '' && $title === '') return '';
        $aud    = is_array($rule['audience'] ?? null) ? $rule['audience'] : [];
        $atype  = (string) ($aud['type'] ?? 'everyone');
        $aval   = $aud['value'] ?? '';
        $q      = static fn(string $s): string => var_export($s, true); // safe single-quoted literal

        // Recipient call (uses $__d for the payload; $row for the record).
        switch ($atype) {
            case 'role':
                $call = "\\PpPhp\\Core\\Notification::AddForRole(" . $q((string) $aval) . ", \$__d)"; break;
            case 'group':
                $call = "\\PpPhp\\Core\\Notification::AddForGroup(" . (int) $aval . ", \$__d)"; break;
            case 'users':
                $ids = implode(', ', array_map('intval', is_array($aval) ? $aval : []));
                $call = "\\PpPhp\\Core\\Notification::AddForUsers([{$ids}], \$__d)"; break;
            case 'owner':
                $field = preg_replace('/[^A-Za-z0-9_]/', '', (string) $aval) ?: 'id';
                $call = "\\PpPhp\\Core\\Notification::AddForRecordOwner(\$row, " . $q($field) . ", \$__d)"; break;
            case 'actor':
                $call = "\\PpPhp\\Core\\Notification::AddForUsers([(int) (\\PpPhp\\Core\\Security::CurrentUserData()['id'] ?? 0)], \$__d)"; break;
            case 'everyone':
            default:
                $call = "\\PpPhp\\Core\\Notification::AddForEveryone(\$__d)"; break;
        }

        // Link: record → the view page (not for deletes); custom → rendered; none → omit.
        $urlLine = '';
        $link = (string) ($rule['link'] ?? 'record');
        if ($link === 'record' && $trigger !== 'delete') {
            $urlLine = "\$__d['url'] = (defined('APP_BASE') ? constant('APP_BASE') : '') . '/generated/" . $table . "/view.php?id=' . \$__id;";
        } elseif ($link === 'custom' && (string) ($rule['custom'] ?? '') !== '') {
            $urlLine = "\$__d['url'] = pp_notif_render(" . $q((string) $rule['custom']) . ", \$__row);";
        }

        $lines = [
            "// Notification Rule (auto-generated) — {$atype} on record {$trigger}",
            "\$row = (array) (\$context['record'] ?? []);",
            "\$__id = (int) (\$context['id'] ?? 0);",
            "\$__row = ['id' => \$__id] + \$row;",
            "\\PpPhp\\Core\\Notification::SetTableName(" . $q($notifTable) . ");",
            "\$__d = ['type' => " . $q($ntype) . ", 'message' => pp_notif_render(" . $q($msg) . ", \$__row), 'title' => pp_notif_render(" . $q($title) . ", \$__row)];",
        ];
        if ($urlLine !== '') $lines[] = $urlLine;
        $lines[] = $call . ";";
        return implode("\n", $lines);
    }

    public function generateEvents(string $outputDir, array $eventsState): array
    {
        $this->log('Generating events runtime…');
        // Compile no-code Notification Rules into the table event hooks so they
        // ride the same pipeline as hand-written event code.
        $eventsState = $this->mergeNotifRules($eventsState);
        $written = [];

        $appDir = $outputDir . '/app';
        if (!is_dir($appDir)) mkdir($appDir, 0755, true);
        $coreDir = $outputDir . '/core';
        if (!is_dir($coreDir)) mkdir($coreDir, 0755, true);
        $jsDir = $outputDir . '/assets/js';
        if (!is_dir($jsDir)) mkdir($jsDir, 0755, true);

        // ── core/Events.php — runtime dispatcher ──
        $dispatcher = <<<'PHPEOF'
<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * Event dispatcher for hooks defined in the PPphp Events designer.
 * Hooks live in app/hooks.php keyed by dotted path, e.g.
 * 'tables.customers.page.beforeAddSave' or 'global.auth.afterLogin'.
 *
 * Pass by-reference context entries to let hooks modify them:
 *   Events::fire('…beforeAddSave', ['data' => &$data]);
 */
final class Events
{
    private static ?array $hooks = null;

    private static function hooks(): array
    {
        if (self::$hooks === null) {
            $file = \BASE_PATH . '/app/hooks.php';
            self::$hooks = is_file($file) ? (require $file) : [];
        }
        return self::$hooks;
    }

    public static function has(string $key): bool
    {
        return isset(self::hooks()[$key]);
    }

    /** Fire a hook; returns the hook's return value, or null when undefined. */
    public static function fire(string $key, array $context = []): mixed
    {
        $hooks = self::hooks();
        if (!isset($hooks[$key])) {
            return null;
        }
        try {
            return ($hooks[$key])($context);
        } catch (\Throwable $e) {
            error_log('[events] ' . $key . ' failed: ' . $e->getMessage());
            return null;
        }
    }
}
PHPEOF;
        $written[] = $this->writeFile($coreDir . '/Events.php', $dispatcher);

        // ── app/hooks.php — enabled PHP hooks as closures ──
        $phpHooks = $this->collectPhpHooks($eventsState);
        $hooksContent = "<?php\n\ndeclare(strict_types=1);\n\n"
            . "use PpPhp\\Core\\Security;\n\n"
            . "// Generated from the PPphp Events designer — regenerated on every build.\n\n"
            . "return [\n";
        foreach ($phpHooks as $key => $code) {
            $body = preg_replace('/^/m', '        ', rtrim($this->normalizeSnippet($code)));
            $hooksContent .= '    ' . var_export($key, true) . " => static function (array \$__ctx) {\n"
                . "        extract(\$__ctx, EXTR_REFS);\n"
                . $body . "\n"
                . "    },\n";
        }
        $hooksContent .= "];\n";
        $written[] = $this->writeFile($appDir . '/hooks.php', $hooksContent);
        $this->log('Exported ' . count($phpHooks) . ' PHP event hooks');

        // ── app/functions.php — shared custom PHP functions + server actions ──
        $phpFns = $eventsState['global']['functions']['php'] ?? [];
        $fnCode = (!empty($phpFns['enabled']) && trim((string)($phpFns['code'] ?? '')) !== '')
            ? $this->normalizeSnippet((string)$phpFns['code'])
            : '// No custom PHP functions defined.';

        // Server actions — each becomes action_<name>($params), dispatched by api/event.php
        // via pp.run('<name>', params) on the client. Guarded so a same-named custom
        // function above always wins.
        $actionsCode = '';
        foreach ((array)($eventsState['global']['actions'] ?? []) as $act) {
            if (!is_array($act) || empty($act['enabled'])) continue;
            $name = preg_replace('/[^A-Za-z0-9_]/', '', (string)($act['name'] ?? ''));
            if ($name === '' || !preg_match('/^[A-Za-z_]/', $name)) continue;
            if (trim((string)($act['code'] ?? '')) === '') continue;
            $body = preg_replace('/^/m', '        ', rtrim($this->normalizeSnippet((string)$act['code'])));
            $actionsCode .= "\nif (!function_exists('action_{$name}')) {\n"
                . "    function action_{$name}(\$params) {\n"
                . $body . "\n"
                . "    }\n}\n";
        }

        $fnFile = "<?php\n\ndeclare(strict_types=1);\n\nuse PpPhp\\Core\\Security;\n\n"
            . "// Custom functions from the PPphp Events designer.\n\n" . rtrim($fnCode) . "\n";
        if ($actionsCode !== '') {
            $fnFile .= "\n// ── Server actions (callable from JS via pp.run('name', params)) ──\n" . $actionsCode;
        }
        $written[] = $this->writeFile($appDir . '/functions.php', $fnFile);

        // ── assets/js/events.js — client-side events + dispatcher ──
        $written[] = $this->writeFile($jsDir . '/events.js', $this->buildEventsJs($eventsState));

        // ── cron.php — scheduled task runner ──
        $written[] = $this->writeFile($outputDir . '/cron.php', $this->buildCronRunner($eventsState));

        return $written;
    }

    /**
     * Collect every enabled server-side (PHP) hook from the events state,
     * validated against the EventCatalogue, keyed by dotted path.
     *
     * @return array<string, string> path => code
     */
    private function collectPhpHooks(array $eventsState): array
    {
        $hooks = [];
        $globalGroups = EventCatalogue::globalGroups();
        $tableGroups  = EventCatalogue::tableGroups();
        $fieldGroups  = EventCatalogue::fieldGroups();

        $enabledCode = static function ($entry): ?string {
            if (!is_array($entry) || empty($entry['enabled'])) return null;
            $code = trim((string)($entry['code'] ?? ''));
            return $code !== '' ? $code : null;
        };

        foreach ($eventsState['global'] ?? [] as $groupKey => $events) {
            if (in_array($groupKey, ['schedule', 'functions', 'actions'], true) || !is_array($events)) continue;
            foreach ($events as $eventKey => $entry) {
                $def = $globalGroups[$groupKey]['events'][$eventKey] ?? null;
                if ($def === null || ($def['lang'] ?? 'php') !== 'php') continue;
                $code = $enabledCode($entry);
                if ($code !== null) $hooks["global.$groupKey.$eventKey"] = $code;
            }
        }

        foreach ($eventsState['tables'] ?? [] as $tableName => $groups) {
            if (!is_array($groups)) continue;
            foreach ($groups as $groupKey => $events) {
                if ($groupKey === 'fields') {
                    foreach ((array)$events as $fieldName => $fieldGroupsState) {
                        foreach ((array)$fieldGroupsState as $fgKey => $fEvents) {
                            $fgDef = $fieldGroups[$fgKey] ?? null;
                            if ($fgDef === null || ($fgDef['lang'] ?? 'php') !== 'php') continue;
                            foreach ((array)$fEvents as $eventKey => $entry) {
                                if (!isset($fgDef['events'][$eventKey])) continue;
                                $code = $enabledCode($entry);
                                if ($code !== null) {
                                    $hooks["tables.$tableName.fields.$fieldName.$fgKey.$eventKey"] = $code;
                                }
                            }
                        }
                    }
                    continue;
                }
                $grpDef = $tableGroups[$groupKey] ?? null;
                if ($grpDef === null || !is_array($events)) continue;
                foreach ($events as $eventKey => $entry) {
                    $def = $grpDef['events'][$eventKey] ?? null;
                    if ($def === null) continue;
                    $lang = $def['lang'] ?? $grpDef['lang'] ?? 'php';
                    if ($lang !== 'php') continue;
                    $code = $enabledCode($entry);
                    if ($code !== null) $hooks["tables.$tableName.$groupKey.$eventKey"] = $code;
                }
            }
        }

        return $hooks;
    }

    /** Strip PHP open/close tags so a designer snippet can be embedded in a closure body. */
    private function normalizeSnippet(string $code): string
    {
        $code = preg_replace('/^\s*<\?php\s*/', '', $code);
        $code = preg_replace('/\?>\s*$/', '', $code);
        return trim($code, "\r\n");
    }

    /** Build assets/js/events.js: custom JS functions, global OnLoad, table + field client events, dispatcher. */
    private function buildEventsJs(array $eventsState): string
    {
        $tableGroups = EventCatalogue::tableGroups();
        $fieldGroups = EventCatalogue::fieldGroups();

        // Documented argument names per client event (used as wrapper signatures)
        $clientArgs = [
            'beforeListRender'   => 'data',
            'afterListRender'    => 'data, container',
            'onRowClick'         => 'record, row, event',
            'onRowSelect'        => 'records, ids',
            'beforeFormSubmit'   => 'data, form, action',
            'afterFormSubmit'    => 'response, action',
            'afterAddSuccess'    => 'record',
            'afterEditSuccess'   => 'record, id',
            'afterDeleteSuccess' => 'id',
            'onError'            => 'error, action',
            'onPageInit'         => 'page',
        ];
        $fieldArgs = [
            'onChange' => 'value, form',
            'onFocus'  => 'value, form',
            'onBlur'   => 'value, form',
            'onKeyup'  => 'value, event, form',
        ];

        $enabledCode = static function ($entry): ?string {
            if (!is_array($entry) || empty($entry['enabled'])) return null;
            $code = trim((string)($entry['code'] ?? ''));
            return $code !== '' ? $code : null;
        };

        // Custom JS functions
        $customJs = '';
        $jsFns = $eventsState['global']['functions']['js'] ?? [];
        if (!empty($jsFns['enabled']) && trim((string)($jsFns['code'] ?? '')) !== '') {
            $customJs = trim((string)$jsFns['code']);
        }

        // Global JavaScript OnLoad
        $jsOnLoad = $enabledCode($eventsState['global']['app']['jsOnLoad'] ?? null) ?? '';

        // Global real-time client events (global.realtime.*, JS ones)
        $realtimeArgs = [
            'onRealtimeConnect'    => 'status',
            'onRealtimeMessage'    => 'data, meta',
            'onRealtimeDisconnect' => 'reason',
            'onPresenceChange'     => 'channel, members',
        ];
        $realtimeEventsJs = '';
        foreach ($realtimeArgs as $eventKey => $sig) {
            $code = $enabledCode($eventsState['global']['realtime'][$eventKey] ?? null);
            if ($code === null) continue;
            $body = preg_replace('/^/m', '            ', rtrim($code));
            $realtimeEventsJs .= "        {$eventKey}: function ({$sig}) {\n{$body}\n        },\n";
        }

        // Table client events
        $tableEventsJs = '';
        foreach ($eventsState['tables'] ?? [] as $tableName => $groups) {
            $entries = '';
            foreach ((array)($groups['client'] ?? []) as $eventKey => $entry) {
                if (!isset($tableGroups['client']['events'][$eventKey], $clientArgs[$eventKey])) continue;
                $code = $enabledCode($entry);
                if ($code === null) continue;
                $body = preg_replace('/^/m', '            ', rtrim($code));
                $entries .= "        {$eventKey}: function ({$clientArgs[$eventKey]}) {\n{$body}\n        },\n";
            }
            if ($entries !== '') {
                $tableEventsJs .= '    ' . json_encode((string)$tableName) . ": {\n{$entries}    },\n";
            }
        }

        // Field client events
        $fieldEventsJs = '';
        foreach ($eventsState['tables'] ?? [] as $tableName => $groups) {
            $fieldsOut = '';
            foreach ((array)($groups['fields'] ?? []) as $fieldName => $fieldGroupsState) {
                $entries = '';
                foreach ((array)($fieldGroupsState['client'] ?? []) as $eventKey => $entry) {
                    if (!isset($fieldGroups['client']['events'][$eventKey], $fieldArgs[$eventKey])) continue;
                    $code = $enabledCode($entry);
                    if ($code === null) continue;
                    $body = preg_replace('/^/m', '                ', rtrim($code));
                    $entries .= "            {$eventKey}: function ({$fieldArgs[$eventKey]}) {\n{$body}\n            },\n";
                }
                if ($entries !== '') {
                    $fieldsOut .= '        ' . json_encode((string)$fieldName) . ": {\n{$entries}        },\n";
                }
            }
            if ($fieldsOut !== '') {
                $fieldEventsJs .= '    ' . json_encode((string)$tableName) . ": {\n{$fieldsOut}    },\n";
            }
        }

        $jsOnLoadBody = $jsOnLoad !== '' ? preg_replace('/^/m', '        ', rtrim($jsOnLoad)) : '        // (none defined)';

        return <<<JSEOF
/**
 * Client-side events from the PPphp Events designer.
 * Regenerated on every build — do not edit by hand.
 */
(function () {
    'use strict';

    // ── PPphp client runtime (window.pp) ──────────────────────────
    // Bridges server (PHP) events to client (JS) events + handy helpers.
    var __PP = window.__PP__ || {};
    var __body = document.body;
    var __ppev = {
        // page context
        page: {
            table: (__body && __body.getAttribute('data-pp-table')) || '',
            action: (__body && __body.getAttribute('data-pp-action')) || '',
            id: new URLSearchParams(location.search).get('id')
        },
        base: __PP.base || window.PP_BASE || '',
        // server → client values: set with pp_set_proxy() in a PHP event,
        // read here as pp.proxy.<key> or pp.getProxy('<key>')
        proxy: __PP.proxy || {},
        getProxy: function (key, def) {
            return (this.proxy && Object.prototype.hasOwnProperty.call(this.proxy, key)) ? this.proxy[key] : def;
        },
        // current record on view/edit (pp_set_proxy('record', \$row) in a PHP event)
        record: function () { return (__PP.record != null) ? __PP.record : this.getProxy('record', null); },
        // AJAX helper — returns a Promise of parsed JSON
        ajax: {
            get: function (url) {
                return fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); });
            },
            post: function (url, data) {
                return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(data || {}) }).then(function (r) { return r.json(); });
            }
        },
        // Call a named server action — runs PHP function action_<name>(params)
        // (defined in Custom Functions → PHP) and resolves to its return value.
        run: function (action, params) {
            var base = __PP.base || window.PP_BASE || '';
            params = params || {};
            try {
                if (params.__table === undefined) params.__table = (document.body && document.body.getAttribute('data-pp-table')) || '';
                if (params.__id === undefined) params.__id = new URLSearchParams(location.search).get('id') || '';
                if (params.__keys === undefined && window.Runner && document.querySelector('.ppd-select-row')) params.__keys = window.Runner.getSelectedKeys();
            } catch (e) { /* ignore */ }
            return fetch(base + '/api/event.php', {
                method: 'POST', credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
                body: JSON.stringify({ action: action, params: params })
            }).then(function (r) { return r.json(); }).then(function (j) {
                if (j && j.commands && window.Runner) window.Runner.apply(j.commands);
                if (j && j.success) return j.result;
                throw new Error((j && j.error) || 'Action failed');
            });
        },
        // dialogs — use SweetAlert2 when present, else native fallbacks
        dialog: {
            toast: function (type, msg) {
                if (window.Swal) Swal.fire({ toast: true, position: 'top-end', icon: type || 'success', title: msg, timer: 2600, showConfirmButton: false });
                else console.log('[toast]', type, msg);
            },
            alert: function (title, text) {
                if (window.Swal) Swal.fire({ title: title, text: text || '' }); else window.alert(title + (text ? ' — ' + text : ''));
            },
            confirm: function (title, text) {
                return window.Swal
                    ? Swal.fire({ icon: 'question', title: title, text: text || '', showCancelButton: true, reverseButtons: true }).then(function (r) { return !!r.isConfirmed; })
                    : Promise.resolve(window.confirm(title + (text ? ' — ' + text : '')));
            }
        },
        // ── Form field helpers (Add / Edit pages) ──
        form: {
            _f: function () { return document.querySelector('main form[method="post"]') || document.querySelector('form'); },
            el: function (name) { var f = this._f(); return document.getElementById('field_' + name) || (f && f.querySelector('[name="' + name + '"]')); },
            wrap: function (name) { var el = this.el(name); return el ? (el.closest('.pp-fld') || el.closest('label') || el.parentElement) : null; },
            get: function (name) { var el = this.el(name); if (!el) return undefined; return (el.type === 'checkbox') ? el.checked : el.value; },
            set: function (name, v) { var el = this.el(name); if (!el) return false; if (el.type === 'checkbox') el.checked = !!v; else el.value = (v == null ? '' : v); el.dispatchEvent(new Event('change', { bubbles: true })); return true; },
            show: function (name) { var w = this.wrap(name); if (w) w.style.display = ''; return this; },
            hide: function (name) { var w = this.wrap(name); if (w) w.style.display = 'none'; return this; },
            enable: function (name) { var el = this.el(name); if (el) el.disabled = false; return this; },
            disable: function (name) { var el = this.el(name); if (el) el.disabled = true; return this; },
            readonly: function (name, on) { var el = this.el(name); if (el) el.readOnly = (on !== false); return this; },
            required: function (name, on) { var el = this.el(name); if (el) el.required = (on !== false); return this; },
            focus: function (name) { var el = this.el(name); if (el) el.focus(); return this; },
            message: function (name, msg) {
                var w = this.wrap(name); if (!w) return this;
                var m = w.querySelector('.pp-field-msg');
                if (!m) { m = document.createElement('div'); m.className = 'pp-field-msg'; m.style.cssText = 'color:var(--pp-danger-text,#dc2626);font-size:.8rem;margin-top:2px'; w.appendChild(m); }
                m.textContent = msg || ''; m.style.display = msg ? '' : 'none'; return this;
            },
            clearMessage: function (name) { return this.message(name, ''); },
            data: function () { var f = this._f(); var o = {}; if (f) new FormData(f).forEach(function (v, k) { o[k] = v; }); return o; }
        },
        // shorthand: pp.val('name') to read, pp.val('name', v) to write
        val: function (name, value) { return (arguments.length > 1) ? this.form.set(name, value) : this.form.get(name); },
        // ── List grid + button/record object ──
        grid: {
            rows: function () { return Array.prototype.slice.call(document.querySelectorAll('.ppd-grid-table tbody tr[data-id]')); },
            rowData: function (tr) {
                if (!tr) return null;
                var raw = tr.getAttribute('data-pp-rec');
                if (raw) { try { return JSON.parse(raw); } catch (e) { } }
                return { id: tr.getAttribute('data-id') };
            },
            selectedIds: function () { return Array.prototype.map.call(document.querySelectorAll('.ppd-select-row:checked'), function (cb) { return cb.value; }); },
            selectedRecords: function () { var g = this; return Array.prototype.map.call(document.querySelectorAll('.ppd-select-row:checked'), function (cb) { return g.rowData(cb.closest('tr')); }); },
            allRecords: function () { var g = this; return this.rows().map(function (tr) { return g.rowData(tr); }); },
            reload: function () { location.reload(); }
        }
    };
    // Merge onto any existing window.pp (the shipped pp.js library loads first) so
    // BOTH APIs coexist regardless of load order: keep pp.js's richer ajax/dialog,
    // UNION the form field-helpers, and add the page/record/grid/run/proxy/val
    // namespaces this event runtime provides.
    window.pp = window.pp || {};
    Object.keys(__ppev).forEach(function (k) {
        if (k === 'form') { window.pp.form = Object.assign(__ppev.form, window.pp.form || {}); }
        else if (!(k in window.pp)) { window.pp[k] = __ppev[k]; }
    });
    // Button object — mirrors PHPRunner's getCurrentRecord()/getSelected() for
    // custom toolbar & row buttons.
    window.pp.button = {
        getCurrentRecord: function () { return window.pp.record(); },
        getSelectedIds: function () { return window.pp.grid.selectedIds(); },
        getSelectedRecords: function () { return window.pp.grid.selectedRecords(); }
    };

    // ── Custom JavaScript functions ──
{$this->indent($customJs !== '' ? $customJs : '// (none defined)', '    ')}

    // ── Global JavaScript OnLoad ──
    var globalOnLoad = function (page) {
{$jsOnLoadBody}
    };

    // ── Table client events ──
    var tableEvents = {
{$tableEventsJs}    };

    // ── Field client events ──
    var fieldEvents = {
{$fieldEventsJs}    };

    function fire(fn, args) {
        if (typeof fn !== 'function') return undefined;
        try { return fn.apply(null, args); }
        catch (e) { console.error('[events]', e); return undefined; }
    }

    // ── Real-time: global handlers + the pp.realtime client (SSE → polling) ──
    var realtimeEvents = {
{$realtimeEventsJs}    };
    (function () {
        // The app base is derived from this script's own resolved URL
        // (…/assets/js/events.js) — works whether the <script src> is relative or absolute.
        var base = '';
        var scripts = document.getElementsByTagName('script');
        for (var si = 0; si < scripts.length; si++) {
            var mm = /^(.*)\\/assets\\/js\\/events\\.js(\\?.*)?\$/.exec(scripts[si].src || '');
            if (mm) { base = mm[1]; break; }
        }
        var url = base + '/api/realtime.php';

        var channels = [], handlers = {}, lastId = 0, es = null, pollTimer = null;
        var mode = 'closed', down = false, baselined = false;

        function meta(m) { return { channel: m.channel, event: m.event, id: m.id }; }
        function dispatch(m) {
            if (typeof m.id === 'number' && m.id > lastId) lastId = m.id;
            if (m.event === '__presence') { setPresence(m.channel, (m.data && m.data.members) || []); return; }
            (handlers[m.event] || []).slice().forEach(function (fn) {
                try { fn(m.data, meta(m)); } catch (e) { console.error('[realtime]', e); }
            });
            fire(realtimeEvents.onRealtimeMessage, [m.data, meta(m)]);
        }
        function stop() {
            if (es) { try { es.close(); } catch (e) {} es = null; }
            if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
        }
        function qs() { return '?channels=' + encodeURIComponent(channels.join(',')) + '&since=' + lastId; }
        function recovered(via) { if (down) { down = false; fire(realtimeEvents.onRealtimeConnect, [via]); } }
        function dropped(reason) { if (!down) { down = true; fire(realtimeEvents.onRealtimeDisconnect, [reason]); } }

        // ── Client→server + presence ──
        var presenceChannels = {}, presenceOn = [], presenceTimer = null;
        var memberTok = (function () {
            try { var k = sessionStorage.getItem('pp_rt_member'); if (!k) { k = Math.random().toString(36).slice(2) + Date.now().toString(36); sessionStorage.setItem('pp_rt_member', k); } return k; }
            catch (e) { return Math.random().toString(36).slice(2); }
        })();
        function post(action, extra) {
            var payload = { action: action };
            for (var k in extra) { if (Object.prototype.hasOwnProperty.call(extra, k)) payload[k] = extra[k]; }
            return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
                .then(function (r) { return r.json(); }).catch(function () { return {}; });
        }
        function sameMembers(a, b) {
            if (a.length !== b.length) return false;
            var sa = a.map(function (x) { return x.member; }).sort().join(',');
            var sb = b.map(function (x) { return x.member; }).sort().join(',');
            return sa === sb;
        }
        function setPresence(ch, members) {
            members = members || [];
            if (presenceChannels[ch] && sameMembers(presenceChannels[ch], members)) return;
            presenceChannels[ch] = members;
            fire(realtimeEvents.onPresenceChange, [ch, members]);
        }
        function heartbeat() {
            presenceOn.forEach(function (ch) {
                post('presence', { channel: ch, member: memberTok }).then(function (j) { if (j && j.members) setPresence(ch, j.members); });
            });
        }
        window.addEventListener('beforeunload', function () {
            presenceOn.forEach(function (ch) {
                try { navigator.sendBeacon(url, new Blob([JSON.stringify({ action: 'leave', channel: ch, member: memberTok })], { type: 'application/json' })); } catch (e) {}
            });
        });

        function open() {
            stop();
            if (!channels.length) { mode = 'closed'; return; }
            // Baseline once ("start at now") so history is never replayed, then
            // always poll/stream forward from lastId.
            if (!baselined) {
                fetch(url + '?channels=' + encodeURIComponent(channels.join(',')) + '&hello=1', { credentials: 'same-origin' })
                    .then(function (r) { return r.json(); })
                    .then(function (j) { if (j && typeof j.lastId === 'number') lastId = j.lastId; })
                    .catch(function () {})
                    .finally(function () { baselined = true; open(); });
                return;
            }
            if (window.EventSource) {
                mode = 'connecting';
                try {
                    es = new EventSource(url + qs() + '&stream=1');
                    es.onopen = function () { mode = 'open'; recovered('open'); };
                    es.onmessage = function (ev) { try { dispatch(JSON.parse(ev.data)); } catch (e) {} };
                    es.onerror = function () { dropped('connection lost'); stop(); mode = 'polling'; poll(); };
                    return;
                } catch (e) { /* fall through to polling */ }
            }
            mode = 'polling'; poll();
        }
        function poll() {
            if (!channels.length) { mode = 'closed'; return; }
            fetch(url + qs(), { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
                .then(function (r) { return r.json(); })
                .then(function (j) {
                    recovered('polling');
                    if (j && j.messages) j.messages.forEach(dispatch);
                    if (j && typeof j.lastId === 'number' && j.lastId > lastId) lastId = j.lastId;
                })
                .catch(function () { dropped('offline'); })
                .finally(function () { pollTimer = setTimeout(poll, 3000); });
        }

        window.pp = window.pp || {};
        window.pp.realtime = {
            subscribe: function (ch) { ch = String(ch || ''); if (ch && channels.indexOf(ch) === -1) { channels.push(ch); open(); } },
            unsubscribe: function (ch) { var i = channels.indexOf(String(ch || '')); if (i !== -1) { channels.splice(i, 1); open(); } },
            on: function (ev, fn) { if (ev && typeof fn === 'function') (handlers[ev] = handlers[ev] || []).push(fn); },
            off: function (ev, fn) { if (handlers[ev]) handlers[ev] = handlers[ev].filter(function (f) { return f !== fn; }); },
            publish: function (ch, ev, data) { return post('publish', { channel: String(ch || ''), event: String(ev || 'message'), data: data }); },
            presence: function (ch) {
                ch = String(ch || ''); if (!ch) return;
                if (presenceOn.indexOf(ch) === -1) presenceOn.push(ch);
                if (channels.indexOf(ch) === -1) { channels.push(ch); open(); }   // also receive __presence pushes
                heartbeat();
                if (!presenceTimer) presenceTimer = setInterval(heartbeat, 15000);
            },
            whoIsHere: function (ch) { return (presenceChannels[String(ch || '')] || []).slice(); },
            status: function () { return mode; }
        };

        // Auto-start when the app defines any real-time handler: fire On Connect
        // (so authors can subscribe there), then open once channels exist.
        function start() {
            fire(realtimeEvents.onRealtimeConnect, ['open']);
            setTimeout(function () { if (channels.length) open(); }, 0);
        }
        if (realtimeEvents.onRealtimeConnect || realtimeEvents.onRealtimeMessage || realtimeEvents.onRealtimeDisconnect || realtimeEvents.onPresenceChange) {
            if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
            else start();
        }
    })();

    document.addEventListener('DOMContentLoaded', function () {
        var body = document.body;
        var table = body.getAttribute('data-pp-table') || '';
        var action = body.getAttribute('data-pp-action') || '';
        var page = { table: table, action: action };
        var T = tableEvents[table] || {};
        var F = fieldEvents[table] || {};

        fire(globalOnLoad, [page]);
        fire(T.onPageInit, [page]);

        if (action === 'list') {
            fire(T.afterListRender, [{ table: table }, document.querySelector('.ppd-grid-table-wrap')]);
            Array.prototype.forEach.call(document.querySelectorAll('.ppd-grid-table tbody tr'), function (tr) {
                tr.addEventListener('click', function (ev) {
                    if (ev.target.closest('button, input, a')) return;
                    fire(T.onRowClick, [window.pp.grid.rowData(tr), tr, ev]);
                });
            });
            document.addEventListener('change', function (ev) {
                if (ev.target.classList && (ev.target.classList.contains('ppd-select-row') || ev.target.classList.contains('ppd-select-all'))) {
                    var ids = window.pp.grid.selectedIds();
                    fire(T.onRowSelect, [window.pp.grid.selectedRecords(), ids]);
                }
            });
            if (window.jQuery) {
                window.jQuery(document).ajaxSuccess(function (_e, _xhr, settings) {
                    if ((settings.url || '').indexOf('/api/delete.php') !== -1) {
                        fire(T.afterDeleteSuccess, [null]);
                    }
                });
            }
        }

        if (action === 'add' || action === 'edit') {
            var formEl = document.querySelector('main form[method="post"]');
            var formApi = window.pp.form;   // shared field-helper object
            if (formEl) {
                formEl.addEventListener('submit', function (ev) {
                    var data = {};
                    new FormData(formEl).forEach(function (v, k) { data[k] = v; });
                    var r = fire(T.beforeFormSubmit, [data, formEl, action]);
                    if (r === false) ev.preventDefault();
                });
            }
            Object.keys(F).forEach(function (fname) {
                var el = document.getElementById('field_' + fname);
                if (!el) return;
                var fe = F[fname];
                if (fe.onChange) el.addEventListener('change', function () { fire(fe.onChange, [el.value, formApi]); });
                if (fe.onFocus)  el.addEventListener('focus',  function () { fire(fe.onFocus,  [el.value, formApi]); });
                if (fe.onBlur)   el.addEventListener('blur',   function () { fire(fe.onBlur,   [el.value, formApi]); });
                if (fe.onKeyup)  el.addEventListener('keyup',  function (ev2) { fire(fe.onKeyup, [el.value, ev2, formApi]); });
            });
        }
    });
})();
JSEOF;
    }

    private function indent(string $text, string $prefix): string
    {
        return preg_replace('/^/m', $prefix, rtrim($text));
    }

    /** Build cron.php: CLI runner for scheduled tasks with crontab suggestions. */
    private function buildCronRunner(array $eventsState): string
    {
        $tasksPhp = '';
        foreach ((array)($eventsState['global']['schedule'] ?? []) as $task) {
            if (!is_array($task) || ($task['enabled'] ?? true) === false) continue;
            $code = trim((string)($task['code'] ?? ''));
            if ($code === '') continue;
            $id   = (string)($task['id'] ?? md5((string)($task['name'] ?? '')));
            $body = preg_replace('/^/m', '            ', rtrim($this->normalizeSnippet($code)));
            $tasksPhp .= '    ' . var_export($id, true) . " => [\n"
                . "        'name' => " . var_export((string)($task['name'] ?? 'Untitled Task'), true) . ",\n"
                . "        'cron' => " . var_export((string)($task['cron'] ?? '0 * * * *'), true) . ",\n"
                . "        'run'  => static function () {\n"
                . $body . "\n"
                . "        },\n"
                . "    ],\n";
        }

        // Built-in task: dispatch due Calendar reminders (email + in-app) when
        // either delivery path is enabled. dispatchReminders() handles both
        // method='email' and method='notify' rows.
        $calS = $this->calendarSettings();
        if (!empty($calS['enabled']) && (!empty($calS['emailReminders']) || !empty($calS['appReminders']))) {
            $tasksPhp = "    'calendar-reminders' => [\n"
                . "        'name' => 'Calendar reminders (email + in-app)',\n"
                . "        'cron' => '* * * * *',\n"
                . "        'run'  => static function () {\n"
                . "            \\PpPhp\\Core\\Calendar\\CalendarService::dispatchReminders();\n"
                . "        },\n"
                . "    ],\n" . $tasksPhp;
        }

        return <<<PHPEOF
<?php

declare(strict_types=1);

/**
 * Scheduled task runner — generated from the PPphp Events designer.
 *
 * Usage:
 *   php cron.php --list        Show tasks and suggested crontab lines
 *   php cron.php --all         Run every task now
 *   php cron.php <task-id>     Run one task
 */

if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('CLI only.');
}

require_once __DIR__ . '/_runtime.php';

\$tasks = [
{$tasksPhp}];

\$arg = \$argv[1] ?? '--list';

if (\$arg === '--list') {
    if (\$tasks === []) {
        echo "No scheduled tasks defined.\\n";
        exit(0);
    }
    echo "Scheduled tasks:\\n";
    foreach (\$tasks as \$id => \$task) {
        echo "  {\$id}  [{\$task['cron']}]  {\$task['name']}\\n";
        echo "    crontab: {\$task['cron']} php " . __FILE__ . " {\$id}\\n";
    }
    exit(0);
}

\$toRun = \$arg === '--all' ? \$tasks : (isset(\$tasks[\$arg]) ? [\$arg => \$tasks[\$arg]] : null);
if (\$toRun === null) {
    fwrite(STDERR, "Unknown task: {\$arg}\\n");
    exit(1);
}

foreach (\$toRun as \$id => \$task) {
    echo '[' . date('Y-m-d H:i:s') . "] Running {\$task['name']} ({\$id})…\\n";
    try {
        (\$task['run'])();
        echo "  done\\n";
    } catch (\\Throwable \$e) {
        fwrite(STDERR, "  FAILED: {\$e->getMessage()}\\n");
    }
}
PHPEOF;
    }

    public function copyCore(string $outputDir): array
    {
        $this->log('Copying core framework files…');
        $written = [];

        $coreFiles = [
            'Helpers.php',
            'Autoloader.php',
            'Container.php',
            'Config.php',
            'DB.php',
            'DatabaseConnection.php',
            'Security.php',
            'QueryResult.php',
            'Pagination.php',
            'Search.php',
            // Field-control plugin system (Fields module View As / Edit As)
            'PluginRegistry.php',
            'PluginSupport.php',   // shared trait used by the built-in plugins (e/safeUrl/formatDate/distinctOptions)
            'EditPlugin.php',
            'ViewPlugin.php',
            'FilterPlugin.php',
            'FieldPlugin.php',
            'ValidationPlugin.php',
            // Messaging (Misc module Email / SMS configuration)
            'Email.php',
            'SMS.php',
            'SmsDriver.php',
            // AI assistant (Anthropic Claude) — powers api/ai-assist.php
            'Ai/AiClient.php',
            // Server-side PDF generation (dompdf-backed) — headless PDFs
            'PdfService.php',
            // Server-side companion to pp.js — pp() facade + client command bus
            'PpApi.php',
            'PpQuery.php',
            // PHPRunner-style SQL variables (:session.x, :user.field, :userid …)
            'SqlVars.php',
            // i18n: chrome-string catalogue + live DB translation overlay (Phase 2)
            'I18n/UiStrings.php',
            'I18n/TranslationStore.php',
        ];

        $sourceBase = dirname(__DIR__, 2) . '/core';
        $destBase = $outputDir . '/core';

        if (!is_dir($destBase)) {
            mkdir($destBase, 0755, true);
        }

        foreach ($coreFiles as $file) {
            $src = $sourceBase . '/' . $file;
            $dest = $destBase . '/' . $file;
            if (is_file($src)) {
                $destDir = dirname($dest);
                if (!is_dir($destDir)) mkdir($destDir, 0755, true);
                copy($src, $dest);
                $this->log("Copied core/$file");
                $written[] = $dest;
            } else {
                $this->log("Skipped core/$file (not found at source)");
            }
        }

        // Authentication protection runtime (core/Auth). Copied WHOLE so a new
        // guard class becomes available to every generated app without a build
        // change — the same reason DataOps below is copied wholesale.
        $authSrc = $sourceBase . '/Auth';
        if (is_dir($authSrc)) {
            $authDest = $destBase . '/Auth';
            if (!is_dir($authDest)) mkdir($authDest, 0755, true);
            foreach (glob($authSrc . '/*.php') ?: [] as $src) {
                $dest = $authDest . '/' . basename($src);
                copy($src, $dest);
                $this->log('Copied core/Auth/' . basename($src));
                $written[] = $dest;
            }
        }

        // Storage runtime (core/Storage) — the modular file-storage drivers the
        // File/Image plugin + api/upload.php + the Image/File view controls use.
        // Copied WHOLE so a new backend (S3 / Google Drive / OneDrive) becomes
        // available in every generated app with no build change (same rationale
        // as Auth + DataOps below).
        $storageSrc = $sourceBase . '/Storage';
        if (is_dir($storageSrc)) {
            $storageDest = $destBase . '/Storage';
            if (!is_dir($storageDest)) mkdir($storageDest, 0755, true);
            foreach (glob($storageSrc . '/*.php') ?: [] as $src) {
                $dest = $storageDest . '/' . basename($src);
                copy($src, $dest);
                $this->log('Copied core/Storage/' . basename($src));
                $written[] = $dest;
            }
        }

        // Real-time runtime (core/Realtime) — the pure-PHP message bus behind the
        // live event hooks (pp_broadcast / pp.realtime / api/realtime.php). Copied
        // WHOLE so a new transport becomes available with no build change.
        $rtSrc = $sourceBase . '/Realtime';
        if (is_dir($rtSrc)) {
            $rtDest = $destBase . '/Realtime';
            if (!is_dir($rtDest)) mkdir($rtDest, 0755, true);
            foreach (glob($rtSrc . '/*.php') ?: [] as $src) {
                $dest = $rtDest . '/' . basename($src);
                copy($src, $dest);
                $this->log('Copied core/Realtime/' . basename($src));
                $written[] = $dest;
            }
        }

        // Validation runtime (core/Validation) — consumes the Validation plugins.
        $valSrc = $sourceBase . '/Validation/Validator.php';
        if (is_file($valSrc)) {
            $valDest = $destBase . '/Validation/Validator.php';
            if (!is_dir(dirname($valDest))) mkdir(dirname($valDest), 0755, true);
            copy($valSrc, $valDest);
            $this->log('Copied core/Validation/Validator.php');
            $written[] = $valDest;
        }

        // Import/Export runtime (core/DataOps). Copied WHOLE — the registry
        // discovers formats by class, so dropping a new Formats/*.php or
        // Readers/*.php file here makes it available in every generated app
        // with no build change. This is why import.php/export.php are thin.
        foreach (['', 'Formats', 'Readers'] as $sub) {
            $srcDir = $sourceBase . '/DataOps' . ($sub !== '' ? '/' . $sub : '');
            if (!is_dir($srcDir)) continue;
            $dstDir = $destBase . '/DataOps' . ($sub !== '' ? '/' . $sub : '');
            if (!is_dir($dstDir)) mkdir($dstDir, 0755, true);
            foreach (glob($srcDir . '/*.php') ?: [] as $src) {
                $dest = $dstDir . '/' . basename($src);
                copy($src, $dest);
                $written[] = $dest;
            }
        }
        $this->log('Copied core/DataOps (import/export runtime + format registry)');

        // Calendar runtime (core/Calendar). Copied WHOLE — the generated
        // calendar page is a thin bootstrap that delegates to CalendarService,
        // so a new class here becomes available to every generated app with no
        // build change (same rationale as Auth / DataOps above).
        $calSrc = $sourceBase . '/Calendar';
        if (is_dir($calSrc)) {
            $calDest = $destBase . '/Calendar';
            if (!is_dir($calDest)) mkdir($calDest, 0755, true);
            foreach (glob($calSrc . '/*.php') ?: [] as $src) {
                $dest = $calDest . '/' . basename($src);
                copy($src, $dest);
                $written[] = $dest;
            }
            $this->log('Copied core/Calendar (calendar runtime)');
        }

        // Field-control runtime + shared lookup helper (core/Designer/FieldControl)
        foreach (['FieldControlRuntime.php', 'LookupOptions.php'] as $fcFile) {
            $fcSrc = $sourceBase . '/Designer/FieldControl/' . $fcFile;
            if (is_file($fcSrc)) {
                $fcDest = $destBase . '/Designer/FieldControl/' . $fcFile;
                if (!is_dir(dirname($fcDest))) mkdir(dirname($fcDest), 0755, true);
                copy($fcSrc, $fcDest);
                $this->log('Copied core/Designer/FieldControl/' . $fcFile);
                $written[] = $fcDest;
            }
        }

        // SEO, write-action, safe-HTML, full-text search and spam-guard
        // runtimes (core/Seo, core/Actions, core/Html, core/Search, core/Guard).
        // Copied WHOLE — same rationale as Auth / DataOps / Calendar above: the
        // front controller and generated pages reference these classes, and a
        // new class in any of these namespaces becomes available to every
        // generated app with no build change. (Without this, an app that opts
        // into SEO-friendly URLs / public read / custom actions fatals with
        // "Class PpPhp\\Core\\Seo\\SlugRouter not found".)
        foreach (['Seo', 'Actions', 'Html', 'Search', 'Guard'] as $ns) {
            $nsSrc = $sourceBase . '/' . $ns;
            if (!is_dir($nsSrc)) continue;
            $nsDest = $destBase . '/' . $ns;
            if (!is_dir($nsDest)) mkdir($nsDest, 0755, true);
            foreach (glob($nsSrc . '/*.php') ?: [] as $src) {
                $dest = $nsDest . '/' . basename($src);
                copy($src, $dest);
                $written[] = $dest;
            }
            $this->log('Copied core/' . $ns . ' runtime');
        }

        // Field-control plugins (View / Edit / Filter) + SMS drivers
        $pluginsSrc = dirname(__DIR__, 2) . '/plugins';
        foreach (['View', 'Edit', 'Filter', 'Validation', 'SmsDrivers'] as $cat) {
            if (is_dir($pluginsSrc . '/' . $cat)) {
                $this->copyRecursive($pluginsSrc . '/' . $cat, $outputDir . '/plugins/' . $cat);
                $this->log("Copied plugins/$cat");
            }
        }

        // Bundled PHP libraries (dompdf) for server-side PDF (PdfService). The
        // whole vendor/ tree — Composer autoloader + dompdf + its deps — so the
        // generated app can produce PDFs headlessly, with no Composer needed.
        $vendorSrc = dirname(__DIR__, 2) . '/vendor';
        if (is_dir($vendorSrc)) {
            $this->copyRecursive($vendorSrc, $outputDir . '/vendor');
            $this->log('Copied vendor/ (dompdf server-side PDF library)');
        }

        return $written;
    }

    /**
     * Write config/mail.php + config/sms.php from the Misc module so the
     * generated app can send email/SMS (pp_send_email / pp_send_sms helpers
     * and the Events email hooks use them).
     */
    public function generateMessagingConfig(string $outputDir): array
    {
        $written = [];
        $configDir = $outputDir . '/config';
        if (!is_dir($configDir)) mkdir($configDir, 0755, true);

        $email = (array) ($this->ctx['email'] ?? []);
        if (trim((string) ($email['host'] ?? '')) !== '' || trim((string) ($email['provider'] ?? '')) !== '') {
            $written[] = $this->writeFile(
                $configDir . '/mail.php',
                "<?php\n\n// Email configuration — from the Misc module.\nreturn " . var_export($email, true) . ";\n"
            );
            $this->log('Wrote config/mail.php (provider: ' . (string) ($email['provider'] ?? 'smtp') . ')');
        }

        $sms = (array) ($this->ctx['sms'] ?? []);
        if (trim((string) ($sms['provider'] ?? '')) !== '' && trim((string) ($sms['api_token'] ?? '')) !== '') {
            $written[] = $this->writeFile(
                $configDir . '/sms.php',
                "<?php\n\n// SMS configuration — from the Misc module.\nreturn " . var_export($sms, true) . ";\n"
            );
            $this->log('Wrote config/sms.php (provider: ' . (string) $sms['provider'] . ')');
        }

        // AI (Anthropic Claude) — powers the AI assistant features. The API key is
        // a secret; it is written to config/ai.php only when set (else AiClient
        // falls back to the ANTHROPIC_API_KEY environment variable).
        $ai = (array) ($this->ctx['ai'] ?? []);
        if (trim((string) ($ai['api_key'] ?? '')) !== '' || trim((string) ($ai['model'] ?? '')) !== '') {
            $written[] = $this->writeFile(
                $configDir . '/ai.php',
                "<?php\n\n// AI configuration — from the Misc module. Keep this file private.\nreturn " . var_export($ai, true) . ";\n"
            );
            $this->log('Wrote config/ai.php (model: ' . (string) ($ai['model'] ?? 'default') . (trim((string) ($ai['api_key'] ?? '')) !== '' ? ', key set' : '') . ')');
        }

        return $written;
    }

    public function copyAssets(string $outputDir): array
    {
        $this->log('Copying framework assets…');
        $written = [];
        $sourceBase = dirname(__DIR__, 2) . '/public/assets';

        $directories = [
            'bootstrap' => 'assets/bootstrap',
            'icons'     => 'assets/icons',
            'jquery'    => 'assets/jquery',
            'sweetalert2' => 'assets/sweetalert2',
            'vendor/fontawesome' => 'assets/vendor/fontawesome',
            'vendor/inter'       => 'assets/vendor/inter',
            'vendor/material-symbols' => 'assets/vendor/material-symbols',
            'vendor/fonts'            => 'assets/vendor/fonts',
            'vendor/flatpickr'        => 'assets/vendor/flatpickr',
            'vendor/nepali-datepicker' => 'assets/vendor/nepali-datepicker',
            'vendor/html2pdf'         => 'assets/vendor/html2pdf',
            'vendor/ppcal'            => 'assets/vendor/ppcal',
            'vendor/pp-uploader'      => 'assets/vendor/pp-uploader',
            'vendor/pp-lightbox'      => 'assets/vendor/pp-lightbox',
        ];

        foreach ($directories as $src => $dest) {
            $srcPath = $sourceBase . '/' . $src;
            $destPath = $outputDir . '/' . $dest;

            if (!is_dir($srcPath)) {
                $this->log("Skipping $src (not found at $srcPath)");
                continue;
            }

            $this->copyRecursive($srcPath, $destPath);
            $this->log("Copied $src → $dest");
            $written[] = $destPath;
        }

        // Self-contained plugin assets: any plugin bundling an assets/ folder has
        // it copied to output/assets/plugins/{Name}/ so the plugin carries its own
        // libraries (referenced via assets/plugins/{Name}/… in its assets()).
        $pluginsSrc = dirname(__DIR__, 2) . '/plugins';
        foreach (['View', 'Edit', 'Filter'] as $cat) {
            $catDir = $pluginsSrc . '/' . $cat;
            if (!is_dir($catDir)) continue;
            foreach (scandir($catDir) ?: [] as $name) {
                if ($name === '.' || $name === '..') continue;
                $bundled = $catDir . '/' . $name . '/assets';
                if (!is_dir($bundled)) continue;
                $destPath = $outputDir . '/assets/plugins/' . $name;
                $this->copyRecursive($bundled, $destPath);
                $this->log("Copied plugin asset bundle $cat/$name → assets/plugins/$name");
                $written[] = $destPath;
            }
        }

        return $written;
    }

    public function cleanOutputDir(string $outputDir): void
    {
        if (!is_dir($outputDir)) return;

        $it = new \RecursiveDirectoryIterator($outputDir, \RecursiveDirectoryIterator::SKIP_DOTS);
        $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($files as $file) {
            if ($file->isDir()) {
                @rmdir($file->getRealPath());
            } else {
                @unlink($file->getRealPath());
            }
        }
        $this->log("Cleaned output directory: $outputDir");
    }

    public function getLogs(): array
    {
        return $this->logs;
    }

    // ── Private ──

    private function copyRecursive(string $src, string $dest): void
    {
        if (!is_dir($dest)) {
            mkdir($dest, 0755, true);
        }

        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($src, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($it as $item) {
            $target = $dest . '/' . $it->getSubPathname();
            if ($item->isDir()) {
                if (!is_dir($target)) mkdir($target, 0755, true);
            } else {
                copy($item->getRealPath(), $target);
            }
        }
    }

    private function log(string $message): void
    {
        $this->logs[] = '[' . date('H:i:s') . '] ' . $message;
    }
}
