<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

/**
 * Renders designer "presentational" components (content / layout / feedback /
 * navigation / dashboard widgets) to HTML for generated pages, mirroring the
 * Designer canvas registry output (same `ppd-*` classes). Container
 * components recurse via the supplied $renderChildren callback.
 *
 * Components the build handles specially (field, form, section, table-grid,
 * list-toolbar, buttons, breadcrumb…) are NOT routed here — this covers the
 * long tail that previously vanished in the build.
 */
final class ComponentRenderer
{
    /**
     * Optional lookup-defs provider for detail grids: fn(string $detailTable)
     * → LookupWizard defs keyed by field name (BuildWriter injects its
     * lookupFieldsFor so embedded child grids resolve FK display values).
     *
     * @var null|callable(string):array<string, array<string, mixed>>
     */
    /** Project brand (Style → Layout → Logo) so Navbar/Footer can inherit it. */
    public static array $appBrand = ['name' => '', 'image' => ''];

    public static $detailLookups = null;

    /**
     * Optional Pages-functionality provider for detail grids:
     * fn(string $detailTable) → ['inlineEdit' =>, 'inlineAdd' =>, 'spreadsheet' =>]
     * so child rows edit in place when the child table's Pages module says so.
     *
     * @var null|callable(string):array<string, bool>
     */
    public static $detailMeta = null;

    /** @param callable(array):string $renderChildren renders a node's children HTML */
    public static function render(array $c, callable $renderChildren, array $fields = []): string
    {
        $type = (string) ($c['type'] ?? '');
        $p = (array) ($c['props'] ?? []);
        $label = (string) ($c['label'] ?? '');

        switch ($type) {
            case 'group-box':
                // Real container: header / content / footer, each its own zone.
                // The CONTENT zone is a 12-column grid so every child's own
                // Size & Layout span actually applies — without that the spans
                // are set but have no grid to span within.
                $gbKids = static function (int $zone) use ($c, $renderChildren): string {
                    $sub = array_values(array_filter((array) ($c['children'] ?? []), static function ($ch) use ($zone) {
                        // No explicit tab ⇒ content. Children authored before
                        // zones existed must not jump into the header.
                        $t = (is_array($ch) && isset($ch['tab']) && is_numeric($ch['tab'])) ? (int) $ch['tab'] : 1;
                        return $t === $zone;
                    }));
                    return $sub === [] ? '' : $renderChildren(['children' => $sub]);
                };
                $gbStyle = (string) ($p['borderStyle'] ?? 'solid');
                if (!in_array($gbStyle, ['solid', 'dashed', 'dotted', 'double', 'none'], true)) $gbStyle = 'solid';
                $gbW  = max(0, min(8, (int) ($p['borderWidth'] ?? 1)));
                $gbR  = preg_match('/^\d{1,3}px$/', (string) ($p['radius'] ?? '')) ? (string) $p['radius'] : '14px';
                $gbPad = (string) ($p['padding'] ?? 'normal');
                if (!in_array($gbPad, ['none', 'tight', 'normal', 'roomy'], true)) $gbPad = 'normal';
                $gbGap = (string) ($p['gap'] ?? 'normal');
                if (!in_array($gbGap, ['tight', 'normal', 'roomy'], true)) $gbGap = 'normal';
                $gbHeadMode = (string) ($p['headerMode'] ?? 'bar');
                if (!in_array($gbHeadMode, ['bar', 'legend', 'plain', 'none'], true)) $gbHeadMode = 'bar';
                $gbAccent = ($p['accent'] ?? true) !== false;
                $gbHead = trim((string) ($p['heading'] ?? ''));
                $gbFoot = trim((string) ($p['footerText'] ?? ''));
                $gbCss = 'border-style:' . $gbStyle . ';border-width:' . $gbW . 'px;border-radius:' . $gbR . ';';

                $headHtml = '';
                if ($gbHeadMode !== 'none') {
                    $headHtml = '<div class="ppd-gb-h">'
                        . ((string) ($p['icon'] ?? '') !== '' ? '<span class="ppd-gb-ic">' . self::ic((string) $p['icon']) . '</span>' : '')
                        . '<span class="ppd-gb-t">' . self::e($gbHead !== '' ? $gbHead : 'Group') . '</span>'
                        . $gbKids(0)
                        . '</div>';
                }
                $footHtml = (($p['showFooter'] ?? true) !== false || $gbFoot !== '' || $gbKids(2) !== '')
                    ? '<div class="ppd-gb-f">' . ($gbFoot !== '' ? '<span>' . self::e($gbFoot) . '</span>' : '') . $gbKids(2) . '</div>'
                    : '';

                return '<section class="ppd-gb hm-' . $gbHeadMode . ' pad-' . $gbPad . ($gbAccent ? ' has-accent' : '')
                    . '" style="' . $gbCss . '">'
                    . $headHtml
                    . '<div class="ppd-gb-b"><div class="pp-zone12 gap-' . $gbGap . '">' . $gbKids(1) . '</div></div>'
                    . $footHtml
                    . '</section>';

            case 'card': {
                // Three zones — header / content / footer — matching the Designer,
                // with full card styling (gradient bg, accent bar, shadow, hover,
                // fonts, header/footer colours, collapsible).
                $cardKids = static function (int $zone) use ($c, $renderChildren): string {
                    $sub = array_values(array_filter((array) ($c['children'] ?? []), static function ($ch) use ($zone) {
                        $t = (is_array($ch) && isset($ch['tab']) && is_numeric($ch['tab'])) ? (int) $ch['tab'] : 1;
                        return $t === $zone;
                    }));
                    return $sub === [] ? '' : $renderChildren(['children' => $sub]);
                };
                $accent = self::cssColor($p['accent'] ?? '');
                $accentSide = ($p['accentSide'] ?? 'top') === 'left' ? 'left' : 'top';
                $shadow = in_array($p['shadow'] ?? 'sm', ['none', 'sm', 'md', 'lg'], true) ? (string) ($p['shadow'] ?? 'sm') : 'sm';
                $hover = filter_var($p['hover'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $foldable = filter_var($p['collapsible'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $collapsed = $foldable && filter_var($p['collapsedInitially'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $hideHeader = filter_var($p['hideHeader'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $bg1 = self::cssColor($p['bg'] ?? '');
                $bg2 = self::cssColor($p['bg2'] ?? '');
                $cs = '';
                if ($bg1 !== '' && $bg2 !== '') {
                    $dir = ['to-right' => 'to right', 'to-bottom' => 'to bottom', 'diagonal' => '135deg'][(string) ($p['bgDir'] ?? '')] ?? 'to bottom';
                    $cs .= 'background:linear-gradient(' . $dir . ',' . $bg1 . ',' . $bg2 . ');';
                } elseif ($bg1 !== '') {
                    $cs .= 'background:' . $bg1 . ';';
                }
                if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $cs .= '--sec-border:' . $v . ';';
                if (($v = self::cssLen((string) ($p['radius'] ?? ''))) !== '') $cs .= '--sec-radius:' . $v . ';';
                if (($v = self::cssColor($p['textColor'] ?? '')) !== '') $cs .= 'color:' . $v . ';';
                if (($v = self::css((string) ($p['font'] ?? ''))) !== '') $cs .= 'font-family:' . $v . ';';
                if ($accent !== '') $cs .= '--card-accent:' . $accent . ';';
                $cardCls = 'ppd-c-section card shadow-' . $shadow . ($hover ? ' is-hover' : '')
                    . ($accent !== '' ? ' is-accent-' . $accentSide : '')
                    . ($foldable ? ' is-foldable' : '') . ($collapsed ? ' is-collapsed' : '');
                // header — colours / align / font; card bg shows through unless a header bg is set
                $hs = '';
                if (($hbg = self::cssColor($p['headerBg'] ?? '')) !== '') $hs .= 'background:' . $hbg . ';';
                elseif ($bg1 !== '') $hs .= 'background:transparent;';
                if (($v = self::cssColor($p['headerColor'] ?? '')) !== '') $hs .= 'color:' . $v . ';';
                if (($v = self::css((string) ($p['headerFont'] ?? ''))) !== '') $hs .= 'font-family:' . $v . ';';
                $ha = (string) ($p['headerAlign'] ?? '');
                if (in_array($ha, ['left', 'center', 'right'], true)) $hs .= 'justify-content:' . ($ha === 'center' ? 'center' : ($ha === 'right' ? 'flex-end' : 'flex-start')) . ';';
                $chev = $foldable ? '<i class="bi bi-chevron-' . ($collapsed ? 'right' : 'down') . ' ppd-fold-ic"></i>' : '';
                $hAttr = $foldable
                    ? ' onclick="this.parentElement.classList.toggle(\'is-collapsed\')" style="cursor:pointer;' . self::e($hs) . '"'
                    : ($hs !== '' ? ' style="' . self::e($hs) . '"' : '');
                $header = $hideHeader ? '' : '<div class="h"' . $hAttr . '>' . self::ic((string) ($p['icon'] ?? 'bi-card-text'))
                    . self::e((string) ($p['heading'] ?? ($label ?: 'Card'))) . $cardKids(0) . $chev . '</div>';
                // body
                $bs = '';
                if (($v = self::cssLen((string) ($p['padding'] ?? ''))) !== '') $bs .= 'padding:' . $v . ';';
                if (($v = self::cssColor($p['bodyBg'] ?? '')) !== '') $bs .= 'background:' . $v . ';';
                $body = '<div class="b"' . ($bs !== '' ? ' style="' . self::e($bs) . '"' : '') . '><div class="pp-zone12 gap-normal">' . $cardKids(1) . '</div></div>';
                // footer
                $cFootTxt = trim((string) ($p['footerText'] ?? ''));
                $fs = '';
                if (($v = self::cssColor($p['footerBg'] ?? '')) !== '') $fs .= 'background:' . $v . ';';
                if (($v = self::cssColor($p['footerColor'] ?? '')) !== '') $fs .= 'color:' . $v . ';';
                $cFoot = (($p['showFooter'] ?? false) !== false || $cFootTxt !== '' || $cardKids(2) !== '')
                    ? '<div class="ppd-c-foot"' . ($fs !== '' ? ' style="' . self::e($fs) . '"' : '') . '>' . ($cFootTxt !== '' ? '<span>' . self::e($cFootTxt) . '</span>' : '') . $cardKids(2) . '</div>'
                    : '';
                return '<div class="' . $cardCls . '"' . ($cs !== '' ? ' style="' . self::e($cs) . '"' : '') . '>' . $header . $body . $cFoot . '</div>';
            }

            case 'menu-card': {
                // Landing-page module card — styleable header / description /
                // footer. Both off → the whole card is one click-through tile to
                // the table's list page. Mirrors registry.js `menu-card`.
                $t       = trim((string) ($p['table'] ?? ''));
                $linkOvr = trim((string) ($p['link'] ?? ''));
                $listUrl = $linkOvr !== '' ? $linkOvr : ($t !== '' ? '/generated/' . $t . '/list.php' : '#');
                $addUrl  = $t !== '' ? '/generated/' . $t . '/add.php' : '#';
                $showDesc = ($p['showDescription'] ?? true) !== false;
                $showFoot = ($p['showFooter'] ?? true) !== false;
                $clickable = !$showDesc && !$showFoot;
                $head = '<div class="pp-mc-head" style="' . self::e(self::mcZone($p, 'header')) . '">'
                    . (trim((string) ($p['icon'] ?? '')) !== '' ? '<span class="pp-mc-ic">' . self::ic((string) $p['icon']) . '</span>' : '')
                    . '<span class="pp-mc-title">' . self::tableCaptionOrText($p, (string) ($p['heading'] ?? $label ?: 'Card')) . '</span></div>';
                $desc = $showDesc
                    ? '<div class="pp-mc-desc" style="' . self::e(self::mcZone($p, 'desc')) . '">' . self::e((string) ($p['description'] ?? '')) . '</div>'
                    : '';
                $foot = '';
                if ($showFoot) {
                    $fIcon = trim((string) ($p['footerIcon'] ?? ''));
                    $fText = trim((string) ($p['footerText'] ?? ''));
                    $lead  = ($fIcon !== '' || $fText !== '')
                        ? '<span class="pp-mc-flabel">' . ($fIcon !== '' ? self::ic($fIcon) : '') . self::e($fText) . '</span>' : '';
                    $foot = '<div class="pp-mc-foot" style="' . self::e(self::mcZone($p, 'footer')) . '">'
                        . $lead . '<span class="pp-mc-actions">'
                        . '<a class="pp-mc-btn" href="' . self::appUrl($listUrl) . '">' . self::ic('bi-box-arrow-in-right') . '<span>Open</span></a>'
                        . ($t !== '' ? '<a class="pp-mc-btn" href="' . self::appUrl($addUrl) . '">' . self::ic('bi-plus-lg') . '<span>Add new</span></a>' : '')
                        . '</span></div>';
                }
                $inner = $head . $desc . $foot;
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $cardStyle = self::mcCardStyle($p);
                $styleAttr = $cardStyle !== '' ? ' style="' . self::e($cardStyle) . '"' : '';
                // Only the generated app gets a real link (the canvas render is inert).
                return $clickable
                    ? '<a class="pp-mc pp-mc-' . $size . ' is-clickable"' . $styleAttr . ' href="' . self::appUrl($listUrl) . '">' . $inner . '</a>'
                    : '<div class="pp-mc pp-mc-' . $size . '"' . $styleAttr . '>' . $inner . '</div>';
            }

            case 'menu-group': {
                // Bordered group (native <fieldset>/<legend> so the heading
                // overlaps the border) holding Menu Cards. Mirrors registry.js.
                static $mgVar = ['legend' => 'pp-mg-legend', 'legend-center' => 'pp-mg-center', 'chip' => 'pp-mg-chip', 'accent' => 'pp-mg-accent', 'soft' => 'pp-mg-soft'];
                $variant = (string) ($p['variant'] ?? 'legend');
                $vcls = $mgVar[$variant] ?? 'pp-mg-legend';
                $sp = in_array($p['spacing'] ?? 'normal', ['tight', 'normal', 'roomy'], true) ? (string) ($p['spacing'] ?? 'normal') : 'normal';
                $eq = (($p['equalHeight'] ?? false) === true || ($p['equalHeight'] ?? '') === 'yes') ? ' pp-mg-eq' : '';
                $st = self::mgBorderStyle($p);
                // Space between the last card row and the group's bottom border.
                $bg = max(0, min(120, (int) ($p['bottomGap'] ?? 18)));
                $st .= 'padding-bottom:' . $bg . 'px;';
                $hs = self::mgHeadStyle($p);
                return '<fieldset class="pp-mg ' . $vcls . '"' . ($st !== '' ? ' style="' . self::e($st) . '"' : '') . '>'
                    . '<legend class="pp-mg-legend"' . ($hs !== '' ? ' style="' . self::e($hs) . '"' : '') . '>'
                    . (trim((string) ($p['icon'] ?? '')) !== '' ? '<span class="pp-mg-ic">' . self::ic((string) $p['icon']) . '</span>' : '')
                    . '<span class="pp-mg-t">' . self::e((string) ($p['heading'] ?? $label ?: 'Group')) . '</span></legend>'
                    // 12-column grid: children honour their own Size & Layout span
                    // (a card at 3/12 → 4 per row); menu-card spans are min-2 capped
                    // (max 6/row) in BuildWriter::wrapComponent.
                    . '<div class="pp-zone12 gap-' . $sp . ' pp-mg-body' . $eq . '">' . $renderChildren($c) . '</div></fieldset>';
            }

            case 'field-group': {
                // Free field placement: a width-spanned SUB-GRID that stacks or
                // nests its children (merge two short fields in one column beside
                // a tall textarea; or split a column into side-by-side fields).
                // The group's own width comes from wrapComponent (--sp); children
                // keep their own spans inside this fresh 12-column grid. On form
                // pages the walker's default branch recurses the children back
                // through itself, so nested `field` components emit REAL inputs.
                $fgGap = in_array($p['gap'] ?? 'normal', ['tight', 'normal', 'roomy'], true) ? (string) ($p['gap'] ?? 'normal') : 'normal';
                return '<div class="pp-zone12 gap-' . $fgGap . '">' . $renderChildren($c) . '</div>';
            }

            case 'layout-grid': {
                // Cell Grid — rows × columns with merge/split (PHPRunner-style).
                // Mirrors registry.js: children carry tab = r*cols + c (their
                // master cell); merged coverage is computed, never stored. On
                // form pages the walker recurses each cell's children, so
                // nested `field` components emit REAL inputs.
                $R = max(1, min(20, (int) ($p['rows'] ?? 2)));
                $C = max(1, min(12, (int) ($p['cols'] ?? 2)));
                // No gap class = "Match form": the grid inherits --pp-form-gap
                // from the form's Spacing (mirror of registry.js).
                $gRaw = (string) ($p['gap'] ?? '');
                $g = in_array($gRaw, ['tight', 'normal', 'roomy'], true) ? ' gap-' . $gRaw : '';
                // Reflow is automatic now: cells carry per-device 12-widths
                // (--w-t/--w-m) that the stylesheet applies on tablet/mobile.
                // (Legacy `reflow` prop no longer emits a class.)
                $rf = '';
                // Vertical alignment of cells sharing a row (mirror of registry.js):
                // 'stretch' (default) = equal-height cards; top/center/bottom align.
                $rowAlignMap = ['top' => 'top', 'center' => 'center', 'bottom' => 'bottom'];
                $ai = ' lg-ai-' . ($rowAlignMap[(string) ($p['rowAlign'] ?? '')] ?? 'stretch');
                $colT = filter_var($p['autoFit'] ?? false, FILTER_VALIDATE_BOOLEAN)
                    ? 'repeat(auto-fit, minmax(' . (preg_replace('/[^0-9a-zA-Z.%]/', '', (string) ($p['minColW'] ?? '')) ?: '220px') . ', 1fr))'
                    : self::lgTracks((string) ($p['colWidths'] ?? ''), $C);
                $rowT = trim((string) ($p['rowHeights'] ?? '')) !== '' ? ';grid-template-rows:' . self::lgRows((string) $p['rowHeights'], $R) : '';
                $cells = (array) ($p['cells'] ?? []);
                $cov = self::lgCovered($R, $C, $cells);
                $kidsAt = static function (int $idx) use ($c, $renderChildren): string {
                    $sub = array_values(array_filter((array) ($c['children'] ?? []), static function ($ch) use ($idx) {
                        $t = (is_array($ch) && isset($ch['tab']) && is_numeric($ch['tab'])) ? (int) $ch['tab'] : 0;
                        return $t === $idx;
                    }));
                    return $sub === [] ? '' : $renderChildren(['children' => $sub]);
                };
                $out = '<div class="pp-lgrid' . $g . $rf . $ai . '" style="grid-template-columns:' . self::e($colT . $rowT) . '">';
                for ($r = 0; $r < $R; $r++) {
                    for ($cc = 0; $cc < $C; $cc++) {
                        if (isset($cov[$r . ':' . $cc])) continue;
                        $cfg = (array) ($cells[$r . ':' . $cc] ?? []);
                        $rs = min(max(1, (int) ($cfg['rs'] ?? 1)), $R - $r);
                        $cs = min(max(1, (int) ($cfg['cs'] ?? 1)), $C - $cc);
                        // 12-column responsive widths (for reflow = responsive):
                        // --w-d desktop (defaults to proportional 12ths), --w-t
                        // tablet, --w-m mobile; --rs row-span for vertical merges.
                        $wD = !empty($cfg['w']) ? max(1, min(12, (int) $cfg['w'])) : max(1, min(12, (int) round($cs / $C * 12)));
                        $csv = '--w-d:' . $wD . ';--rs:' . $rs . ';';
                        if (!empty($cfg['wT'])) $csv .= '--w-t:' . max(1, min(12, (int) $cfg['wT'])) . ';';
                        if (!empty($cfg['wM'])) $csv .= '--w-m:' . max(1, min(12, (int) $cfg['wM'])) . ';';
                        $st = 'grid-row:' . ($r + 1) . ' / span ' . $rs . ';grid-column:' . ($cc + 1) . ' / span ' . $cs . ';' . $csv . self::lgCellStyle($cfg);
                        $out .= '<div class="pp-lgcell' . self::lgCellCls($cfg) . '" style="' . self::e($st) . '">'
                            . '<div class="pp-zone12' . $g . '">' . $kidsAt($r * $C + $cc) . '</div></div>';
                    }
                }
                return $out . '</div>';
            }

            case 'divider':
                return trim((string) ($p['label'] ?? '')) !== ''
                    ? '<div class="ppd-c-divider"><span>' . self::e((string) $p['label']) . '</span></div>'
                    : '<hr class="ppd-c-hr">';

            case 'alert': {
                $tone = (string) ($p['tone'] ?? 'info'); if (!in_array($tone, ['info', 'success', 'warning', 'danger'], true)) $tone = 'info';
                $cls = 'ppd-c-alert ' . $tone;
                $variant = (string) ($p['variant'] ?? 'soft');
                if ($variant === 'solid') $cls .= ' alert-solid'; elseif ($variant === 'outline') $cls .= ' alert-outline'; elseif ($variant === 'accent') $cls .= ' alert-accent';
                $size = (string) ($p['size'] ?? 'md'); if ($size === 'sm') $cls .= ' alert-sm'; elseif ($size === 'lg') $cls .= ' alert-lg';
                $auto = ['info' => 'bi-info-circle', 'success' => 'bi-check-circle', 'warning' => 'bi-exclamation-triangle', 'danger' => 'bi-x-octagon'][$tone];
                $showIcon = filter_var($p['showIcon'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $ico = $showIcon ? '<span' . self::partAttr($p, 'icon', 'ppd-elicon') . '>' . self::ic(trim((string) ($p['icon'] ?? '')) !== '' ? (string) $p['icon'] : $auto) . '</span>' : '';
                $title = trim((string) ($p['title'] ?? '')) !== '' ? '<div class="ppd-c-alert-title">' . self::e((string) $p['title']) . '</div>' : '';
                $at = trim((string) ($p['actionText'] ?? '')); $au = trim((string) ($p['actionUrl'] ?? ''));
                $action = ($at !== '' && $au !== '' && !preg_match('/^\s*javascript:/i', $au)) ? '<a class="ppd-c-alert-action" href="' . self::e($au) . '">' . self::e($at) . '</a>' : '';
                $close = filter_var($p['dismissible'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '<button type="button" class="ppd-c-alert-close" onclick="this.closest(\'.ppd-c-alert\').remove()">&times;</button>' : '';
                $cs = self::customBoxStyle($p);
                return '<div class="' . $cls . '"' . ($cs !== '' ? ' style="' . self::e($cs) . '"' : '') . '>' . $ico . '<div class="ppd-c-alert-body">' . $title
                    . '<span' . self::partAttr($p, 'text') . '>' . self::e((string) ($p['text'] ?? '')) . '</span>' . $action . '</div>' . $close . '</div>';
            }

            case 'heading':
                $lvl = in_array($p['level'] ?? 'h2', ['h1', 'h2', 'h3'], true) ? ($p['level'] ?? 'h2') : 'h2';
                return "<$lvl class=\"ppd-c-heading $lvl\">" . self::e((string) ($p['text'] ?? $label ?: 'Heading')) . "</$lvl>";

            case 'text':
                return '<div class="ppd-c-text">' . self::e((string) ($p['text'] ?? '')) . '</div>';

            case 'image': {
                $imgS = self::imageImgCss($p);
                if (trim((string) ($p['src'] ?? '')) !== '') {
                    $inner = '<img class="ppd-c-image" src="' . self::e((string) $p['src']) . '" alt="' . self::e((string) ($p['alt'] ?? ''))
                        . '" loading="lazy" decoding="async"' . ($imgS !== '' ? ' style="' . self::e($imgS) . '"' : '') . '>';
                } else {
                    $ph = (($p['ratio'] ?? 'auto') && ($p['ratio'] ?? 'auto') !== 'auto') ? self::css((string) $p['ratio']) : '16/9';
                    $inner = '<div class="ppd-c-image placeholder" style="aspect-ratio:' . $ph . '">' . self::ic('bi-image')
                        . '<small>' . self::e((string) ($p['alt'] ?? 'Image')) . '</small></div>';
                }
                $hover = ['zoom' => ' ppd-c-imh-zoom', 'lift' => ' ppd-c-imh-lift', 'glow' => ' ppd-c-imh-glow', 'tilt' => ' ppd-c-imh-tilt'][$p['hover'] ?? ''] ?? '';
                $box = '<div class="ppd-c-imgbox' . $hover . '" style="' . self::e(self::imageBoxCss($p)) . '">' . $inner . '</div>';
                $link = trim((string) ($p['link'] ?? ''));
                // Block javascript:/vbscript:/data: hrefs (XSS) — same guard used by
                // the alert-action and calendar links above.
                if ($link !== '' && !preg_match('~^\s*(javascript|vbscript|data)\s*:~i', $link)) {
                    $nt = filter_var($p['newTab'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                    $box = '<a class="ppd-c-imglink" href="' . self::e($link) . '"' . ($nt ? ' target="_blank" rel="noopener"' : '') . '>' . $box . '</a>';
                }
                return $box;
            }

            case 'navbar': {
                $brandText = trim((string) ($p['brand'] ?? '')) !== '' ? (string) $p['brand'] : (string) (self::$appBrand['name'] ?? 'Brand');
                $logo = trim((string) ($p['logo'] ?? '')) !== '' ? (string) $p['logo'] : (string) (self::$appBrand['image'] ?? '');
                $variant = in_array($p['variant'] ?? '', ['solid', 'transparent', 'blur'], true) ? (string) $p['variant'] : 'solid';
                $menuAlign = in_array($p['menuAlign'] ?? '', ['left', 'center', 'right'], true) ? (string) $p['menuAlign'] : 'right';
                $sticky = filter_var($p['sticky'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showBrand = filter_var($p['showBrand'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $id = 'ppnav-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $cls = 'ppd-c-nav ppd-c-nav--' . $variant . ($sticky ? ' is-sticky' : '') . ' menu-' . $menuAlign;
                $brand = '<a class="ppd-c-nav-brand" href="' . self::e(self::safeHref((string) ($p['home'] ?? '/'))) . '">'
                    . ($logo !== '' ? '<img src="' . self::e($logo) . '" alt="">' : '')
                    . ($showBrand && $brandText !== '' ? '<span>' . self::e($brandText) . '</span>' : '') . '</a>';
                $links = '';
                foreach (self::arr($p['links'] ?? []) as $l) {
                    $l = (array) $l;
                    $links .= '<a class="ppd-c-nav-link" href="' . self::e(self::safeHref((string) ($l['href'] ?? '#'))) . '">' . self::e((string) ($l['label'] ?? 'Link')) . '</a>';
                }
                $cta = '';
                if (filter_var($p['ctaShow'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false && trim((string) ($p['ctaLabel'] ?? '')) !== '') {
                    $cta = '<a class="ppd-c-nav-cta' . (($p['ctaStyle'] ?? '') === 'outline' ? ' is-outline' : '') . '" href="' . self::e(self::safeHref((string) ($p['ctaHref'] ?? '#'))) . '">' . self::e((string) $p['ctaLabel']) . '</a>';
                }
                $full = ($p['width'] ?? '') === 'full' ? ' is-full' : '';
                return '<header class="' . $cls . '">'
                    . '<input type="checkbox" class="ppd-c-nav-toggle" id="' . self::e($id) . '">'
                    . '<div class="ppd-c-nav-inner' . $full . '">'
                    . $brand
                    . '<label class="ppd-c-nav-burger" for="' . self::e($id) . '" aria-label="Menu"><span></span><span></span><span></span></label>'
                    . '<nav class="ppd-c-nav-menu">' . $links . $cta . '</nav>'
                    . '</div></header>';
            }

            case 'hero': {
                $layout = ($p['layout'] ?? '') === 'split' ? 'split' : 'center';
                $bgType = in_array($p['bgType'] ?? 'none', ['none', 'solid', 'gradient', 'image'], true) ? (string) ($p['bgType'] ?? 'none') : 'none';
                $minH = in_array($p['minH'] ?? 'lg', ['md', 'lg', 'screen'], true) ? (string) ($p['minH'] ?? 'lg') : 'lg';
                $tone = (string) ($p['tone'] ?? 'auto');
                $toneCls = $tone === 'light' ? ' tone-light' : ($tone === 'dark' ? ' tone-dark' : (($bgType === 'image' || $bgType === 'gradient') ? ' tone-light' : ''));
                $bgCss = '';
                if ($bgType === 'solid' && self::cssColor($p['bg1'] ?? '') !== '') $bgCss = 'background:' . self::cssColor($p['bg1']) . ';';
                elseif ($bgType === 'gradient' && self::cssColor($p['bg1'] ?? '') !== '') {
                    $ang = (int) ($p['bgAngle'] ?? 135);
                    $c2 = self::cssColor($p['bg2'] ?? '') ?: self::cssColor($p['bg1']);
                    $bgCss = 'background:linear-gradient(' . $ang . 'deg,' . self::cssColor($p['bg1']) . ',' . $c2 . ');';
                } elseif ($bgType === 'image' && trim((string) ($p['bgImage'] ?? '')) !== '') {
                    $bgCss = 'background-image:url(' . self::e((string) $p['bgImage']) . ');background-size:cover;background-position:center;';
                }
                $ov = '';
                if ($bgType === 'image' && (int) ($p['overlay'] ?? 0) > 0) $ov = '<div class="ppd-c-hero-ov" style="opacity:' . ((int) $p['overlay'] / 100) . '"></div>';
                $eyebrow = trim((string) ($p['eyebrow'] ?? '')) !== '' ? '<span class="ppd-c-hero-eyebrow">' . self::e((string) $p['eyebrow']) . '</span>' : '';
                $cta = '<div class="ppd-c-hero-cta">';
                if (trim((string) ($p['primaryLabel'] ?? '')) !== '') $cta .= '<a class="ppd-c-hero-btn is-primary" href="' . self::e(self::safeHref((string) ($p['primaryHref'] ?? '#'))) . '">' . self::e((string) $p['primaryLabel']) . '</a>';
                if (filter_var($p['secondaryShow'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false && trim((string) ($p['secondaryLabel'] ?? '')) !== '') $cta .= '<a class="ppd-c-hero-btn is-ghost" href="' . self::e(self::safeHref((string) ($p['secondaryHref'] ?? '#'))) . '">' . self::e((string) $p['secondaryLabel']) . '</a>';
                $cta .= '</div>';
                $align = ($p['align'] ?? '') === 'left' ? 'left' : 'center';
                $text = '<div class="ppd-c-hero-text align-' . $align . '">' . $eyebrow
                    . '<h1 class="ppd-c-hero-h">' . self::e((string) ($p['headline'] ?? 'Headline')) . '</h1>'
                    . (trim((string) ($p['subhead'] ?? '')) !== '' ? '<p class="ppd-c-hero-sub">' . self::e((string) $p['subhead']) . '</p>' : '') . $cta . '</div>';
                $media = '';
                if ($layout === 'split') {
                    $img = trim((string) ($p['image'] ?? ''));
                    $media = '<div class="ppd-c-hero-media">' . ($img !== '' ? '<img src="' . self::e($img) . '" alt="">' : '<div class="ppd-c-hero-ph">' . self::ic('bi-image') . '</div>') . '</div>';
                }
                $cls = 'ppd-c-hero layout-' . $layout . ' h-' . $minH . $toneCls . ($bgType === 'image' ? ' has-img' : '');
                return '<section class="' . $cls . '"' . ($bgCss !== '' ? ' style="' . self::e($bgCss) . '"' : '') . '>' . $ov . '<div class="ppd-c-hero-inner">' . $text . $media . '</div></section>';
            }

            case 'feature-grid': {
                $cols = in_array((string) ($p['columns'] ?? '3'), ['2', '3', '4'], true) ? (string) ($p['columns'] ?? '3') : '3';
                $heading = trim((string) ($p['heading'] ?? ''));
                $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '')
                    ? '<div class="ppd-c-feat-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $plain = ($p['variant'] ?? '') === 'plain' ? ' is-plain' : '';
                $align = ($p['align'] ?? '') === 'center' ? 'center' : 'left';
                $icPlain = ($p['iconStyle'] ?? '') === 'plain' ? ' is-plain' : '';
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $items .= '<div class="ppd-c-feat' . $plain . ' align-' . $align . '">'
                        . '<div class="ppd-c-feat-ic' . $icPlain . '">' . self::ic((string) ($it['icon'] ?? 'bi-star')) . '</div>'
                        . '<h3>' . self::e((string) ($it['title'] ?? 'Feature')) . '</h3>'
                        . (trim((string) ($it['text'] ?? '')) !== '' ? '<p>' . self::e((string) $it['text']) . '</p>' : '') . '</div>';
                }
                return '<section class="ppd-c-features"><div class="ppd-c-feat-wrap">' . $head . '<div class="ppd-c-feat-grid cols-' . $cols . '">' . $items . '</div></div></section>';
            }

            case 'footer': {
                $brandText = trim((string) ($p['brand'] ?? '')) !== '' ? (string) $p['brand'] : (string) (self::$appBrand['name'] ?? '');
                $logo = trim((string) ($p['logo'] ?? '')) !== '' ? (string) $p['logo'] : (string) (self::$appBrand['image'] ?? '');
                $showBrand = filter_var($p['showBrand'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $groups = [];
                $order = [];
                foreach (self::arr($p['links'] ?? []) as $l) {
                    $l = (array) $l;
                    $g = trim((string) ($l['group'] ?? '')) ?: 'Links';
                    if (!isset($groups[$g])) { $groups[$g] = []; $order[] = $g; }
                    $groups[$g][] = $l;
                }
                $cols = '';
                foreach ($order as $g) {
                    $li = '';
                    foreach ($groups[$g] as $l) $li .= '<li><a href="' . self::e(self::safeHref((string) ($l['href'] ?? '#'))) . '">' . self::e((string) ($l['label'] ?? 'Link')) . '</a></li>';
                    $cols .= '<div class="ppd-c-foot-col"><h4>' . self::e($g) . '</h4><ul>' . $li . '</ul></div>';
                }
                $social = '';
                foreach (self::arr($p['social'] ?? []) as $s) {
                    $s = (array) $s;
                    $social .= '<a class="ppd-c-foot-soc" aria-label="social" href="' . self::e(self::safeHref((string) ($s['href'] ?? '#'))) . '">' . self::ic((string) ($s['icon'] ?? 'bi-link-45deg')) . '</a>';
                }
                $brandBlock = '<div class="ppd-c-foot-brandcol"><a class="ppd-c-foot-brand">' . ($logo !== '' ? '<img src="' . self::e($logo) . '" alt="">' : '') . ($showBrand && $brandText !== '' ? '<span>' . self::e($brandText) . '</span>' : '') . '</a>'
                    . (trim((string) ($p['tagline'] ?? '')) !== '' ? '<p class="ppd-c-foot-tag">' . self::e((string) $p['tagline']) . '</p>' : '')
                    . ($social !== '' ? '<div class="ppd-c-foot-socs">' . $social . '</div>' : '') . '</div>';
                $copy = self::e(str_replace(['{year}', '{brand}'], [(string) date('Y'), $brandText], (string) ($p['copyright'] ?? '')));
                $variant = ($p['variant'] ?? '') === 'dark' ? 'dark' : 'light';
                return '<footer class="ppd-c-foot variant-' . $variant . '"><div class="ppd-c-foot-main">' . $brandBlock . '<div class="ppd-c-foot-cols">' . $cols . '</div></div><div class="ppd-c-foot-bottom"><span>' . $copy . '</span></div></footer>';
            }

            case 'pricing': {
                $cur = self::e((string) ($p['currency'] ?? '$'));
                $id = 'ppprice-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $plans = self::arr($p['plans'] ?? []);
                $hasYear = false;
                foreach ($plans as $pl) { if (trim((string) (((array) $pl)['priceYear'] ?? '')) !== '') { $hasYear = true; break; } }
                $toggle = (filter_var($p['showToggle'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false && $hasYear)
                    ? '<div class="ppd-c-price-toggle"><span>Monthly</span><label class="ppd-c-price-sw" for="' . self::e($id) . '"><span class="ppd-c-price-knob"></span></label><span>Annual</span></div>' : '';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-price-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $cards = '';
                foreach ($plans as $pl) {
                    $pl = (array) $pl;
                    $feats = '';
                    foreach (preg_split('/\r?\n/', (string) ($pl['features'] ?? '')) as $f) {
                        $f = trim($f); if ($f !== '') $feats .= '<li>' . self::ic('bi-check-lg') . '<span>' . self::e($f) . '</span></li>';
                    }
                    $hi = filter_var($pl['highlighted'] ?? false, FILTER_VALIDATE_BOOLEAN);
                    $period = self::e((string) ($pl['period'] ?? ''));
                    $priceM = '<span class="ppd-c-price-m">' . $cur . self::e((string) ($pl['price'] ?? '0')) . '<em>' . $period . '</em></span>';
                    $priceY = trim((string) ($pl['priceYear'] ?? '')) !== '' ? '<span class="ppd-c-price-y">' . $cur . self::e((string) $pl['priceYear']) . '<em>' . $period . '</em></span>' : '';
                    $cards .= '<div class="ppd-c-plan' . ($hi ? ' is-highlight' : '') . '">'
                        . (trim((string) ($pl['badge'] ?? '')) !== '' ? '<span class="ppd-c-plan-badge">' . self::e((string) $pl['badge']) . '</span>' : '')
                        . '<div class="ppd-c-plan-name">' . self::e((string) ($pl['name'] ?? 'Plan')) . '</div>'
                        . '<div class="ppd-c-plan-price">' . $priceM . $priceY . '</div>'
                        . (trim((string) ($pl['desc'] ?? '')) !== '' ? '<p class="ppd-c-plan-desc">' . self::e((string) $pl['desc']) . '</p>' : '')
                        . '<ul class="ppd-c-plan-feats">' . $feats . '</ul>'
                        . (trim((string) ($pl['ctaLabel'] ?? '')) !== '' ? '<a class="ppd-c-plan-cta' . ($hi ? ' is-primary' : '') . '" href="' . self::e(self::safeHref((string) ($pl['ctaHref'] ?? '#'))) . '">' . self::e((string) $pl['ctaLabel']) . '</a>' : '') . '</div>';
                }
                return '<section class="ppd-c-pricing"><input type="checkbox" class="ppd-c-price-switch" id="' . self::e($id) . '">'
                    . '<div class="ppd-c-price-wrap">' . $head . $toggle . '<div class="ppd-c-price-grid">' . $cards . '</div></div></section>';
            }

            case 'testimonials': {
                $cols = in_array((string) ($p['columns'] ?? '3'), ['1', '2', '3'], true) ? (string) ($p['columns'] ?? '3') : '3';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-tm-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $r = max(0, min(5, (int) ($it['rating'] ?? 0)));
                    $stars = '';
                    if ($r > 0) { for ($i = 0; $i < 5; $i++) $stars .= self::ic($i < $r ? 'bi-star-fill' : 'bi-star'); }
                    $name = (string) ($it['name'] ?? '');
                    $initial = self::e(mb_strtoupper(mb_substr(trim($name) !== '' ? trim($name) : '?', 0, 1)));
                    $av = trim((string) ($it['avatar'] ?? '')) !== '' ? '<img class="ppd-c-tm-av" src="' . self::e((string) $it['avatar']) . '" alt="">' : '<span class="ppd-c-tm-av ppd-c-tm-av-ph">' . $initial . '</span>';
                    $items .= '<figure class="ppd-c-tm">' . ($r ? '<div class="ppd-c-tm-stars">' . $stars . '</div>' : '')
                        . '<blockquote class="ppd-c-tm-quote">' . self::e((string) ($it['quote'] ?? '')) . '</blockquote>'
                        . '<figcaption class="ppd-c-tm-by">' . $av . '<span class="ppd-c-tm-meta"><b>' . self::e($name) . '</b>' . (trim((string) ($it['role'] ?? '')) !== '' ? '<em>' . self::e((string) $it['role']) . '</em>' : '') . '</span></figcaption></figure>';
                }
                return '<section class="ppd-c-testimonials"><div class="ppd-c-tm-wrap">' . $head . '<div class="ppd-c-tm-grid cols-' . $cols . '">' . $items . '</div></div></section>';
            }

            case 'contact-form': {
                $cid = 'cf_' . preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($c['id'] ?? 'x'));
                $recipient = trim((string) ($p['recipient'] ?? ''));
                $recipientValid = filter_var($recipient, FILTER_VALIDATE_EMAIL) ? $recipient : '';
                $subject = trim((string) ($p['subject'] ?? '')) !== '' ? trim((string) $p['subject']) : 'New message from your website';
                $showSubj = filter_var($p['showSubjectField'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $showPhone = filter_var($p['showPhone'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $btn = trim((string) ($p['buttonLabel'] ?? '')) !== '' ? trim((string) $p['buttonLabel']) : 'Send message';
                $success = trim((string) ($p['successMsg'] ?? '')) !== '' ? trim((string) $p['successMsg']) : 'Thanks! Your message has been sent.';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $align = ($p['align'] ?? '') === 'left' ? 'left' : 'center';
                $v = '$__cf' . $cid; // per-form state var
                $recExp = var_export($recipientValid, true);
                $subExp = var_export($subject, true);
                // Inline handler — runs at request time before the form markup. Recipient
                // is a server-side literal (never a form field) → not an open relay.
                $handler = '<?php ' . $v . ' = ["sent"=>false,"error"=>""];'
                    . 'if ((($_SERVER["REQUEST_METHOD"]??"")==="POST") && (($_POST["__ppform"]??"")==="' . $cid . '")) {'
                    . 'if (empty($_POST["__hp"])) {'
                    . '$__to=' . $recExp . ';'
                    . '$__nm=trim((string)($_POST["cf_name"]??""));$__em=trim((string)($_POST["cf_email"]??""));$__ms=trim((string)($_POST["cf_message"]??""));'
                    . '$__ph=trim((string)($_POST["cf_phone"]??""));$__sj=trim(preg_replace("/[\\r\\n]+/"," ",(string)($_POST["cf_subject"]??"")));'
                    . '$__emC=preg_replace("/[\\r\\n]+/"," ",$__em);'
                    . 'if ($__to===""){' . $v . '["error"]="This form is not configured to receive messages yet.";}'
                    . 'elseif ($__nm===""||!filter_var($__em,FILTER_VALIDATE_EMAIL)||$__ms===""){' . $v . '["error"]="Please add your name, a valid email and a message.";}'
                    . 'else{'
                    . '$__body="Name: ".$__nm."\\nEmail: ".$__emC.($__ph!==""?"\\nPhone: ".$__ph:"").($__sj!==""?"\\nSubject: ".$__sj:"")."\\n\\n".$__ms;'
                    . '$__hdr="From: ".$__to."\\r\\nReply-To: ".$__emC."\\r\\nContent-Type: text/plain; charset=utf-8";'
                    . $v . '["sent"]=@mail($__to,' . $subExp . '.($__sj!==""?" - ".$__sj:""),$__body,$__hdr);'
                    . 'if (!' . $v . '["sent"])' . $v . '["error"]="Sorry, the message could not be sent right now. Please try again later.";'
                    . '}}} ?>';
                $fields = '<div class="ppd-c-cf-field"><label>Name</label><input type="text" name="cf_name" required></div>'
                    . ($showPhone ? '<div class="ppd-c-cf-field"><label>Phone</label><input type="tel" name="cf_phone"></div>' : '')
                    . '<div class="ppd-c-cf-field"><label>Email</label><input type="email" name="cf_email" required></div>'
                    . ($showSubj ? '<div class="ppd-c-cf-field"><label>Subject</label><input type="text" name="cf_subject"></div>' : '')
                    . '<div class="ppd-c-cf-field"><label>Message</label><textarea name="cf_message" rows="5" required></textarea></div>';
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-contact-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $ok = '<div class="ppd-c-contact-ok">' . self::ic('bi-check-circle-fill') . '<span>' . self::e($success) . '</span></div>';
                $form = '<form class="ppd-c-contact-form" method="post">'
                    . '<input type="hidden" name="__ppform" value="' . $cid . '">'
                    . '<input type="text" name="__hp" class="ppd-c-hp" tabindex="-1" autocomplete="off" aria-hidden="true">'
                    . $fields . '<button type="submit" class="ppd-c-contact-btn">' . self::e($btn) . '</button></form>';
                return $handler . '<section class="ppd-c-contact align-' . $align . '"><div class="ppd-c-contact-wrap">' . $head
                    . '<?php if (!empty(' . $v . '["sent"])): ?>' . $ok
                    . '<?php else: ?>'
                    . '<?php if (!empty(' . $v . '["error"])): ?><div class="ppd-c-contact-err">' . self::ic('bi-exclamation-triangle') . '<span><?= htmlspecialchars((string)' . $v . '["error"]) ?></span></div><?php endif; ?>'
                    . $form
                    . '<?php endif; ?></div></section>';
            }

            case 'announcement-bar': {
                $id = 'ppannc-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $sty = ''; $bg = self::cssColor($p['bg'] ?? ''); if ($bg !== '') $sty .= 'background:' . $bg . ';';
                $co = self::cssColor($p['color'] ?? ''); if ($co !== '') $sty .= 'color:' . $co . ';';
                $link = trim((string) ($p['linkLabel'] ?? '')) !== '' ? ' <a class="ppd-c-annc-link" href="' . self::e(self::safeHref((string) ($p['linkHref'] ?? '#'))) . '">' . self::e((string) $p['linkLabel']) . '</a>' : '';
                $close = filter_var($p['dismissible'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false ? '<label class="ppd-c-annc-close" for="' . self::e($id) . '" aria-label="Dismiss">&times;</label>' : '';
                $align = ($p['align'] ?? '') === 'left' ? 'left' : 'center';
                return '<div class="ppd-c-annc-wrap"><input type="checkbox" class="ppd-c-annc-x" id="' . self::e($id) . '"><div class="ppd-c-annc align-' . $align . '"' . ($sty !== '' ? ' style="' . self::e($sty) . '"' : '') . '><div class="ppd-c-annc-in"><span>' . self::e((string) ($p['text'] ?? '')) . '</span>' . $link . '</div>' . $close . '</div></div>';
            }

            case 'logo-cloud': {
                $prelude = '';
                $t = self::ident($p['table'] ?? '');
                if ($t !== '') {
                    // Live logos: one row → one logo, image (and alt) from bound columns.
                    // Built into $__lcHtml once so a marquee can echo it in both tracks.
                    $imgCol = self::ident($p['imageColumn'] ?? '');
                    $altCol = self::ident($p['altColumn'] ?? '');
                    $oc     = self::ident($p['orderColumn'] ?? '');
                    $od     = strtoupper((string) ($p['orderDir'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
                    $n      = max(1, min(60, (int) ($p['limit'] ?? 12)));
                    $where  = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $order  = $oc !== '' ? " ORDER BY `$oc` $od" : '';
                    $sqlExpr = $where !== ''
                        ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . "' . $order . ' LIMIT ' . $n . '"'
                        : '"SELECT * FROM `' . $t . '`' . $order . ' LIMIT ' . $n . '"';
                    $imgExpr = $imgCol !== '' ? "(string)(\$__r['$imgCol'] ?? '')" : "''";
                    $altExpr = $altCol !== '' ? "(string)(\$__r['$altCol'] ?? '')" : "''";
                    $prelude = "<?php \$__lcHtml=''; try{\$__lc=\\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: [];}catch(\\Throwable \$__e){\$__lc=[];} "
                        . "foreach(\$__lc as \$__r){ \$__img=$imgExpr; if(\$__img==='') continue; \$__alt=$altExpr; "
                        . "\$__lcHtml .= '<span class=\"ppd-c-logo\"><img src=\"'.htmlspecialchars(\$__img,ENT_QUOTES).'\" alt=\"'.htmlspecialchars(\$__alt,ENT_QUOTES).'\"></span>'; } ?>";
                    $items = '<?php echo $__lcHtml; ?>';
                } else {
                    $items = '';
                    foreach (self::arr($p['logos'] ?? []) as $l) {
                        $l = (array) $l;
                        $items .= trim((string) ($l['image'] ?? '')) !== ''
                            ? '<span class="ppd-c-logo"><img src="' . self::e((string) $l['image']) . '" alt="' . self::e((string) ($l['alt'] ?? '')) . '"></span>'
                            : '<span class="ppd-c-logo ppd-c-logo-ph">' . self::ic('bi-image') . '</span>';
                    }
                }
                $marquee = filter_var($p['marquee'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $body = $marquee
                    ? '<div class="ppd-c-logos-marq"><div class="ppd-c-logos-track">' . $items . '</div><div class="ppd-c-logos-track" aria-hidden="true">' . $items . '</div></div>'
                    : '<div class="ppd-c-logos-row">' . $items . '</div>';
                $gray = filter_var($p['grayscale'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $h = trim((string) ($p['heading'] ?? ''));
                return $prelude . '<section class="ppd-c-logos' . ($gray ? ' is-gray' : '') . ($marquee ? ' is-marquee' : '') . '"><div class="ppd-c-logos-wrap">' . ($h !== '' ? '<p class="ppd-c-logos-h">' . self::e($h) . '</p>' : '') . $body . '</div></section>';
            }

            case 'stats-counter': {
                $cols = in_array((string) ($p['columns'] ?? '4'), ['2', '3', '4'], true) ? (string) ($p['columns'] ?? '4') : '4';
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    // A metric is EITHER a live DB aggregate (when a table is bound) or a
                    // manual number. The aggregate mirrors the Summary widget: count/sum/
                    // avg/min/max + SqlVars WHERE, formatted with pp_kpi_fmt; the result
                    // feeds both the visible text and the count-up's data-to.
                    $t = self::ident($it['table'] ?? '');
                    if ($t !== '') {
                        $agg = strtolower((string) ($it['agg'] ?? 'count'));
                        if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
                        $col = self::ident($it['column'] ?? '');
                        $aggSql = ($agg === 'count' || $col === '') ? 'COUNT(*)' : strtoupper($agg) . '(`' . $col . '`)';
                        $where = trim(str_replace(["\0", ';'], '', (string) ($it['where'] ?? '')));
                        $sqlExpr = $where !== ''
                            ? '"SELECT ' . $aggSql . ' FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ')'
                            : '"SELECT ' . $aggSql . ' FROM `' . $t . '`"';
                        $dec = max(0, min(4, (int) ($it['decimals'] ?? 0)));
                        $abbr = filter_var($it['abbreviate'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                        $valSpan = self::ppKpiFmtHelper()
                            . '<?php try{$__sv=(float)(\PpPhp\Core\DB::FetchOne(' . $sqlExpr . ') ?: 0);}catch(\Throwable $__e){$__sv=0;} $__svf=pp_kpi_fmt($__sv,' . $dec . ',' . $abbr . ',true); ?>'
                            . '<span class="ppd-c-stat-val" data-to="<?php echo htmlspecialchars($__svf, ENT_QUOTES); ?>"><?php echo htmlspecialchars($__svf); ?></span>';
                    } else {
                        $v = self::e((string) ($it['value'] ?? ''));
                        $valSpan = '<span class="ppd-c-stat-val" data-to="' . $v . '">' . $v . '</span>';
                    }
                    $items .= '<div class="ppd-c-stat"><div class="ppd-c-stat-num"><span class="ppd-c-stat-pre">' . self::e((string) ($it['prefix'] ?? '')) . '</span>' . $valSpan . '<span class="ppd-c-stat-suf">' . self::e((string) ($it['suffix'] ?? '')) . '</span></div><div class="ppd-c-stat-lbl">' . self::e((string) ($it['label'] ?? '')) . '</div></div>';
                }
                $h = trim((string) ($p['heading'] ?? ''));
                return '<section class="ppd-c-stats"><div class="ppd-c-stats-wrap">' . ($h !== '' ? '<h2 class="ppd-c-stats-h">' . self::e($h) . '</h2>' : '') . '<div class="ppd-c-stats-grid cols-' . $cols . '">' . $items . '</div></div></section>' . self::statsCountUpScript();
            }

            case 'cta-band': {
                $bgType = in_array($p['bgType'] ?? 'gradient', ['solid', 'gradient'], true) ? (string) ($p['bgType'] ?? 'gradient') : 'gradient';
                $bgCss = '';
                if ($bgType === 'solid' && self::cssColor($p['bg1'] ?? '') !== '') $bgCss = 'background:' . self::cssColor($p['bg1']) . ';';
                elseif ($bgType === 'gradient' && self::cssColor($p['bg1'] ?? '') !== '') {
                    $c2 = self::cssColor($p['bg2'] ?? '') ?: self::cssColor($p['bg1']);
                    $bgCss = 'background:linear-gradient(' . (int) ($p['bgAngle'] ?? 135) . 'deg,' . self::cssColor($p['bg1']) . ',' . $c2 . ');';
                }
                $tone = (string) ($p['tone'] ?? 'auto');
                $toneCls = $tone === 'light' ? ' tone-light' : ($tone === 'dark' ? ' tone-dark' : ($bgType === 'gradient' || $bgType === 'solid' ? ' tone-light' : ''));
                $btns = '<div class="ppd-c-cta-btns">';
                if (trim((string) ($p['primaryLabel'] ?? '')) !== '') $btns .= '<a class="ppd-c-hero-btn is-primary" href="' . self::e(self::safeHref((string) ($p['primaryHref'] ?? '#'))) . '">' . self::e((string) $p['primaryLabel']) . '</a>';
                if (filter_var($p['secondaryShow'] ?? false, FILTER_VALIDATE_BOOLEAN) && trim((string) ($p['secondaryLabel'] ?? '')) !== '') $btns .= '<a class="ppd-c-hero-btn is-ghost" href="' . self::e(self::safeHref((string) ($p['secondaryHref'] ?? '#'))) . '">' . self::e((string) $p['secondaryLabel']) . '</a>';
                $btns .= '</div>';
                $align = ($p['align'] ?? '') === 'left' ? 'left' : 'center';
                return '<section class="ppd-c-cta align-' . $align . $toneCls . '"' . ($bgCss !== '' ? ' style="' . self::e($bgCss) . '"' : '') . '><div class="ppd-c-cta-in"><div class="ppd-c-cta-txt"><h2>' . self::e((string) ($p['heading'] ?? '')) . '</h2>' . (trim((string) ($p['subhead'] ?? '')) !== '' ? '<p>' . self::e((string) $p['subhead']) . '</p>' : '') . '</div>' . $btns . '</div></section>';
            }

            case 'team-grid': {
                $cols = in_array((string) ($p['columns'] ?? '3'), ['2', '3', '4'], true) ? (string) ($p['columns'] ?? '3') : '3';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-team-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $m) {
                    $m = (array) $m;
                    $soc = '';
                    foreach (preg_split('/\r?\n/', (string) ($m['socials'] ?? '')) as $line) {
                        $parts = explode('|', $line, 2);
                        $ic = trim($parts[0] ?? ''); $url = trim($parts[1] ?? '#');
                        if ($ic !== '') $soc .= '<a class="ppd-c-team-soc" aria-label="social" href="' . self::e(self::safeHref($url !== '' ? $url : '#')) . '">' . self::ic($ic) . '</a>';
                    }
                    $name = (string) ($m['name'] ?? '');
                    $ph = trim((string) ($m['photo'] ?? '')) !== ''
                        ? '<img class="ppd-c-team-ph" src="' . self::e((string) $m['photo']) . '" alt="">'
                        : '<span class="ppd-c-team-ph ppd-c-team-ph-x">' . self::e(mb_strtoupper(mb_substr(trim($name) !== '' ? trim($name) : '?', 0, 1))) . '</span>';
                    $items .= '<div class="ppd-c-team">' . $ph . '<div class="ppd-c-team-name">' . self::e($name) . '</div><div class="ppd-c-team-role">' . self::e((string) ($m['role'] ?? '')) . '</div>'
                        . (trim((string) ($m['bio'] ?? '')) !== '' ? '<p class="ppd-c-team-bio">' . self::e((string) $m['bio']) . '</p>' : '') . ($soc !== '' ? '<div class="ppd-c-team-socs">' . $soc . '</div>' : '') . '</div>';
                }
                return '<section class="ppd-c-teams"><div class="ppd-c-team-wrap">' . $head . '<div class="ppd-c-team-grid cols-' . $cols . '">' . $items . '</div></div></section>';
            }

            case 'faq': {
                $cols = (string) ($p['columns'] ?? '1') === '2' ? '2' : '1';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-faq-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $openFirst = filter_var($p['openFirst'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $items = ''; $i = 0;
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $items .= '<details class="ppd-c-faq-item"' . (($openFirst && $i === 0) ? ' open' : '') . '><summary class="ppd-c-faq-q"><span>' . self::e((string) ($it['q'] ?? '')) . '</span><i class="bi bi-chevron-down ppd-c-faq-ic"></i></summary><div class="ppd-c-faq-a">' . self::e((string) ($it['a'] ?? '')) . '</div></details>';
                    $i++;
                }
                return '<section class="ppd-c-faq"><div class="ppd-c-faq-wrap">' . $head . '<div class="ppd-c-faq-list cols-' . $cols . '">' . $items . '</div></div></section>';
            }

            case 'carousel': {
                $ar = in_array((string) ($p['aspect'] ?? '16/9'), ['16/9', '4/3', '1/1', '3/2', '21/9'], true) ? (string) ($p['aspect'] ?? '16/9') : '16/9';
                $slides = '';
                foreach (self::arr($p['slides'] ?? []) as $s) {
                    $s = (array) $s;
                    $img = trim((string) ($s['image'] ?? '')) !== '' ? '<img src="' . self::e((string) $s['image']) . '" alt="' . self::e((string) ($s['caption'] ?? '')) . '" loading="lazy">' : '<div class="ppd-c-carou-ph">' . self::ic('bi-image') . '</div>';
                    $slides .= '<div class="ppd-c-carou-slide" style="aspect-ratio:' . self::e($ar) . '">' . $img . (trim((string) ($s['caption'] ?? '')) !== '' ? '<div class="ppd-c-carou-cap">' . self::e((string) $s['caption']) . '</div>' : '') . '</div>';
                }
                $peek = filter_var($p['peek'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $h = trim((string) ($p['heading'] ?? ''));
                return '<section class="ppd-c-carou' . ($peek ? ' is-peek' : '') . '"><div class="ppd-c-carou-wrap">' . ($h !== '' ? '<h2 class="ppd-c-carou-h">' . self::e($h) . '</h2>' : '') . '<div class="ppd-c-carou-track">' . $slides . '</div><div class="ppd-c-carou-hint">' . self::ic('bi-arrow-left-right') . ' Scroll or swipe</div></div></section>';
            }

            case 'gallery': {
                $cols = in_array((string) ($p['columns'] ?? '3'), ['2', '3', '4'], true) ? (string) ($p['columns'] ?? '3') : '3';
                $gap = in_array((string) ($p['gap'] ?? 'md'), ['sm', 'md', 'lg'], true) ? (string) ($p['gap'] ?? 'md') : 'md';
                $gid = 'ppgal-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $lb = filter_var($p['lightbox'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $cells = ''; $i = 0;
                foreach (self::arr($p['images'] ?? []) as $im) {
                    $im = (array) $im;
                    $src = trim((string) ($im['image'] ?? ''));
                    $alt = self::e((string) ($im['alt'] ?? ''));
                    $thumb = $src !== '' ? '<img src="' . self::e($src) . '" alt="' . $alt . '" loading="lazy">' : '<div class="ppd-c-gal-ph">' . self::ic('bi-image') . '</div>';
                    if ($lb && $src !== '') {
                        $anchor = self::e($gid) . '-' . $i;
                        $cells .= '<a class="ppd-c-gal-cell" href="#' . $anchor . '">' . $thumb . '</a><div class="ppd-c-gal-lb" id="' . $anchor . '"><a class="ppd-c-gal-lb-bg" href="#" aria-label="Close"></a><img src="' . self::e($src) . '" alt="' . $alt . '"><a class="ppd-c-gal-lb-x" href="#" aria-label="Close">&times;</a></div>';
                    } else {
                        $cells .= '<span class="ppd-c-gal-cell">' . $thumb . '</span>';
                    }
                    $i++;
                }
                $h = trim((string) ($p['heading'] ?? ''));
                return '<section class="ppd-c-gallery gap-' . $gap . '"><div class="ppd-c-gal-wrap">' . ($h !== '' ? '<h2 class="ppd-c-gal-h">' . self::e($h) . '</h2>' : '') . '<div class="ppd-c-gal-grid cols-' . $cols . '">' . $cells . '</div></div></section>';
            }

            case 'countdown': {
                $id = 'ppcd-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $units = [['days', 'Days'], ['hours', 'Hours'], ['mins', 'Mins'], ['secs', 'Secs']];
                $cells = [];
                foreach ($units as $u) $cells[] = '<div class="ppd-c-cd-cell"><span class="ppd-c-cd-num" data-u="' . $u[0] . '">00</span><span class="ppd-c-cd-lbl">' . $u[1] . '</span></div>';
                $inner = implode('<span class="ppd-c-cd-sep">:</span>', $cells);
                $align = ($p['align'] ?? '') === 'left' ? 'left' : 'center';
                $variant = ($p['variant'] ?? '') === 'plain' ? 'is-plain' : 'is-boxes';
                $h = trim((string) ($p['heading'] ?? ''));
                return '<section class="ppd-c-countdown align-' . $align . '"><div class="ppd-c-cd-wrap">' . ($h !== '' ? '<h2 class="ppd-c-cd-h">' . self::e($h) . '</h2>' : '')
                    . '<div class="ppd-c-cd ' . $variant . '" id="' . self::e($id) . '" data-to="' . self::e((string) ($p['target'] ?? '')) . '" data-expired="' . self::e((string) ($p['expiredText'] ?? '')) . '">' . $inner . '</div></div></section>' . self::countdownScript();
            }

            case 'marquee': {
                $one = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $one .= '<span class="ppd-c-mq-item">' . self::ic('bi-star-fill') . self::e((string) ($it['text'] ?? '')) . '</span>';
                }
                $sty = ''; $bg = self::cssColor($p['bg'] ?? ''); if ($bg !== '') $sty .= 'background:' . $bg . ';';
                $co = self::cssColor($p['color'] ?? ''); if ($co !== '') $sty .= 'color:' . $co . ';';
                $spd = in_array((string) ($p['speed'] ?? 'normal'), ['slow', 'normal', 'fast'], true) ? (string) ($p['speed'] ?? 'normal') : 'normal';
                return '<div class="ppd-c-mq spd-' . $spd . '"' . ($sty !== '' ? ' style="' . self::e($sty) . '"' : '') . '><div class="ppd-c-mq-track"><div class="ppd-c-mq-seq">' . $one . '</div><div class="ppd-c-mq-seq" aria-hidden="true">' . $one . '</div></div></div>';
            }

            case 'social-links': {
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $s) {
                    $s = (array) $s;
                    $items .= '<a class="ppd-c-soc" aria-label="social" href="' . self::e(self::safeHref((string) ($s['href'] ?? '#'))) . '">' . self::ic((string) ($s['icon'] ?? 'bi-link-45deg')) . '</a>';
                }
                $variant = in_array((string) ($p['variant'] ?? 'filled'), ['filled', 'outline', 'plain'], true) ? (string) ($p['variant'] ?? 'filled') : 'filled';
                $size = in_array((string) ($p['size'] ?? 'md'), ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $align = in_array((string) ($p['align'] ?? 'center'), ['left', 'center', 'right'], true) ? (string) ($p['align'] ?? 'center') : 'center';
                $h = trim((string) ($p['heading'] ?? ''));
                return '<section class="ppd-c-socials v-' . $variant . ' s-' . $size . ' align-' . $align . '"><div class="ppd-c-soc-wrap">' . ($h !== '' ? '<span class="ppd-c-soc-h">' . self::e($h) . '</span>' : '') . '<div class="ppd-c-soc-row">' . $items . '</div></div></section>';
            }

            case 'comparison-table': {
                $cols = self::arr($p['columns'] ?? []);
                $head = '<tr><th class="ppd-c-cmp-corner"></th>';
                foreach ($cols as $col) {
                    $col = (array) $col;
                    $hi = filter_var($col['highlighted'] ?? false, FILTER_VALIDATE_BOOLEAN);
                    $head .= '<th' . ($hi ? ' class="is-hi"' : '') . '>' . self::e((string) ($col['name'] ?? '')) . ($hi ? '<span class="ppd-c-cmp-badge">' . self::e(trim((string) ($col['badge'] ?? '')) !== '' ? (string) $col['badge'] : 'Popular') . '</span>' : '') . '</th>';
                }
                $head .= '</tr>';
                $body = '';
                foreach (self::arr($p['rows'] ?? []) as $r) {
                    $r = (array) $r;
                    $vals = preg_split('/\r?\n/', (string) ($r['values'] ?? ''));
                    $tds = '';
                    foreach ($cols as $i => $col) {
                        $col = (array) $col;
                        $v = trim((string) ($vals[$i] ?? ''));
                        if (preg_match('/^(yes|true|✓|y)$/i', $v)) $cell = '<span class="ppd-c-cmp-yes">' . self::ic('bi-check-lg') . '</span>';
                        elseif ($v === '' || preg_match('/^(no|false|✗|x|n|-)$/i', $v)) $cell = '<span class="ppd-c-cmp-no">' . self::ic('bi-dash') . '</span>';
                        else $cell = '<span class="ppd-c-cmp-txt">' . self::e($v) . '</span>';
                        $tds .= '<td' . (filter_var($col['highlighted'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' class="is-hi"' : '') . '>' . $cell . '</td>';
                    }
                    $body .= '<tr><th scope="row">' . self::e((string) ($r['feature'] ?? '')) . '</th>' . $tds . '</tr>';
                }
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $hd = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-cmp-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                return '<section class="ppd-c-compare"><div class="ppd-c-cmp-wrap">' . $hd . '<div class="ppd-c-cmp-scroll"><table class="ppd-c-cmp-tbl"><thead>' . $head . '</thead><tbody>' . $body . '</tbody></table></div></div></section>';
            }

            case 'back-to-top': {
                $pos = ($p['position'] ?? '') === 'left' ? 'left' : 'right';
                $shape = ($p['shape'] ?? '') === 'rounded' ? 'rounded' : 'circle';
                $after = max(50, min(3000, (int) ($p['showAfter'] ?? 400)));
                return '<button type="button" class="ppd-c-totop pos-' . $pos . ' sh-' . $shape . '" data-after="' . $after . '" aria-label="Back to top">' . self::ic((string) ($p['icon'] ?? 'bi-arrow-up')) . '</button>' . self::backToTopScript();
            }

            case 'cookie-consent': {
                $id = 'ppck-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($c['id'] ?? 'x'));
                $pos = ($p['position'] ?? '') === 'bottom-left' ? 'bl' : 'bottom';
                $policy = trim((string) ($p['policyLabel'] ?? '')) !== '' ? ' <a class="ppd-c-cookie-link" href="' . self::e(self::safeHref((string) ($p['policyHref'] ?? '#'))) . '">' . self::e((string) $p['policyLabel']) . '</a>' : '';
                $decline = trim((string) ($p['declineLabel'] ?? '')) !== '' ? '<button type="button" class="ppd-c-cookie-btn is-ghost" data-ck="decline">' . self::e((string) $p['declineLabel']) . '</button>' : '';
                return '<div class="ppd-c-cookie pos-' . $pos . '" id="' . self::e($id) . '"><div class="ppd-c-cookie-msg">' . self::ic('bi-cookie') . '<span>' . self::e((string) ($p['message'] ?? '')) . $policy . '</span></div><div class="ppd-c-cookie-acts">' . $decline . '<button type="button" class="ppd-c-cookie-btn is-primary" data-ck="accept">' . self::e(trim((string) ($p['acceptLabel'] ?? '')) !== '' ? (string) $p['acceptLabel'] : 'Accept') . '</button></div></div>' . self::cookieScript();
            }

            case 'newsletter': {
                $layout = ($p['layout'] ?? '') === 'stacked' ? 'stacked' : 'inline';
                $ph = self::e(trim((string) ($p['placeholder'] ?? '')) !== '' ? (string) $p['placeholder'] : 'you@example.com');
                $btn = self::e(trim((string) ($p['buttonLabel'] ?? '')) !== '' ? (string) $p['buttonLabel'] : 'Subscribe');
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-news-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $action = trim((string) ($p['action'] ?? ''));
                $recipient = filter_var(trim((string) ($p['recipient'] ?? '')), FILTER_VALIDATE_EMAIL) ? trim((string) $p['recipient']) : '';
                // Provider mode: post to an external endpoint (field named "email").
                if ($action !== '' && !preg_match('~^\s*(javascript|vbscript|data)\s*:~i', $action)) {
                    $form = '<form class="ppd-c-news-form" action="' . self::e($action) . '" method="post"><input type="email" name="email" placeholder="' . $ph . '" required><button type="submit" class="ppd-c-news-btn">' . $btn . '</button></form>';
                    return '<section class="ppd-c-news layout-' . $layout . '"><div class="ppd-c-news-wrap">' . $head . $form . '</div></section>';
                }
                // Self-contained mailer mode (no DB): emails the owner each subscriber.
                $cid = 'nl_' . preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($c['id'] ?? 'x'));
                $success = trim((string) ($p['successMsg'] ?? '')) !== '' ? trim((string) $p['successMsg']) : 'You’re subscribed — thanks!';
                $v = '$__nl' . $cid;
                $recExp = var_export($recipient, true);
                $handler = '<?php ' . $v . ' = ["sent"=>false,"error"=>""];'
                    . 'if ((($_SERVER["REQUEST_METHOD"]??"")==="POST") && (($_POST["__ppform"]??"")==="' . $cid . '")) {'
                    . 'if (empty($_POST["__hp"])) {'
                    . '$__to=' . $recExp . ';$__em=trim((string)($_POST["email"]??""));$__emC=preg_replace("/[\\r\\n]+/"," ",$__em);'
                    . 'if ($__to===""){' . $v . '["error"]="Signups are not configured yet.";}'
                    . 'elseif (!filter_var($__em,FILTER_VALIDATE_EMAIL)){' . $v . '["error"]="Please enter a valid email address.";}'
                    . 'else{' . $v . '["sent"]=@mail($__to,"New newsletter subscriber","New subscriber: ".$__emC,"From: ".$__to."\\r\\nReply-To: ".$__emC."\\r\\nContent-Type: text/plain; charset=utf-8");'
                    . 'if (!' . $v . '["sent"])' . $v . '["error"]="Sorry, could not subscribe right now. Please try again later.";}'
                    . '}} ?>';
                $form = '<form class="ppd-c-news-form" method="post"><input type="hidden" name="__ppform" value="' . $cid . '"><input type="text" name="__hp" class="ppd-c-hp" tabindex="-1" autocomplete="off" aria-hidden="true"><input type="email" name="email" placeholder="' . $ph . '" required><button type="submit" class="ppd-c-news-btn">' . $btn . '</button></form>';
                return $handler . '<section class="ppd-c-news layout-' . $layout . '"><div class="ppd-c-news-wrap">' . $head
                    . '<?php if (!empty(' . $v . '["sent"])): ?><div class="ppd-c-news-ok">' . self::ic('bi-check-circle-fill') . '<span>' . self::e($success) . '</span></div>'
                    . '<?php else: ?>'
                    . '<?php if (!empty(' . $v . '["error"])): ?><div class="ppd-c-news-err">' . self::ic('bi-exclamation-triangle') . '<span><?= htmlspecialchars((string)' . $v . '["error"]) ?></span></div><?php endif; ?>'
                    . $form . '<?php endif; ?></div></section>';
            }

            case 'feature-split': {
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-fs-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $rows = ''; $i = 0;
                foreach (self::arr($p['rows'] ?? []) as $r) {
                    $r = (array) $r;
                    $side = ($r['side'] ?? '') === 'left' ? 'left' : (($r['side'] ?? '') === 'right' ? 'right' : ($i % 2 === 0 ? 'left' : 'right'));
                    $media = trim((string) ($r['image'] ?? '')) !== '' ? '<img src="' . self::e((string) $r['image']) . '" alt="" loading="lazy">' : '<div class="ppd-c-fs-ph">' . self::ic('bi-image') . '</div>';
                    $cta = trim((string) ($r['ctaLabel'] ?? '')) !== '' ? '<a class="ppd-c-fs-cta" href="' . self::e(self::safeHref((string) ($r['ctaHref'] ?? '#'))) . '">' . self::e((string) $r['ctaLabel']) . self::ic('bi-arrow-right') . '</a>' : '';
                    $rows .= '<div class="ppd-c-fs-row media-' . $side . '"><div class="ppd-c-fs-media">' . $media . '</div><div class="ppd-c-fs-text">'
                        . (trim((string) ($r['eyebrow'] ?? '')) !== '' ? '<span class="ppd-c-fs-eyebrow">' . self::e((string) $r['eyebrow']) . '</span>' : '')
                        . '<h3>' . self::e((string) ($r['title'] ?? '')) . '</h3>' . (trim((string) ($r['text'] ?? '')) !== '' ? '<p>' . self::e((string) $r['text']) . '</p>' : '') . $cta . '</div></div>';
                    $i++;
                }
                return '<section class="ppd-c-fsplit"><div class="ppd-c-fs-wrap">' . $head . $rows . '</div></section>';
            }

            case 'bento-grid': {
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-bento-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $tiles = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $sz = in_array((string) ($it['size'] ?? 'md'), ['md', 'wide', 'tall', 'big'], true) ? (string) ($it['size'] ?? 'md') : 'md';
                    $accent = self::cssColor($it['accent'] ?? '');
                    $sty = $accent !== '' ? ' style="--bento-accent:' . $accent . '"' : '';
                    $hasImg = trim((string) ($it['image'] ?? '')) !== '';
                    $img = $hasImg ? '<img class="ppd-c-bento-img" src="' . self::e((string) $it['image']) . '" alt="" loading="lazy">' : '';
                    $tiles .= '<div class="ppd-c-bento sz-' . $sz . ($hasImg ? ' has-img' : '') . '"' . $sty . '>' . $img . '<div class="ppd-c-bento-ic">' . self::ic((string) ($it['icon'] ?? 'bi-star')) . '</div><h3>' . self::e((string) ($it['title'] ?? '')) . '</h3>' . (trim((string) ($it['text'] ?? '')) !== '' ? '<p>' . self::e((string) $it['text']) . '</p>' : '') . '</div>';
                }
                return '<section class="ppd-c-bentos"><div class="ppd-c-bento-wrap">' . $head . '<div class="ppd-c-bento-grid">' . $tiles . '</div></div></section>';
            }

            case 'video-embed': {
                $inner = self::videoEmbedHtml($p);
                $sty = '';
                if (($mw = self::cssLen((string) ($p['maxWidth'] ?? ''))) !== '') $sty = 'max-width:' . $mw . ';margin-left:auto;margin-right:auto;';
                $rounded = filter_var($p['rounded'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $shadow = filter_var($p['shadow'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $ar = in_array((string) ($p['aspect'] ?? '16/9'), ['16/9', '4/3', '1/1', '21/9', '9/16'], true) ? (string) ($p['aspect'] ?? '16/9') : '16/9';
                $cap = trim((string) ($p['caption'] ?? ''));
                return '<figure class="ppd-c-video' . ($rounded ? ' is-rounded' : '') . ($shadow ? ' is-shadow' : '') . '"' . ($sty !== '' ? ' style="' . self::e($sty) . '"' : '') . '>'
                    . '<div class="ppd-c-video-frame" style="aspect-ratio:' . self::e($ar) . '">' . $inner . '</div>'
                    . ($cap !== '' ? '<figcaption>' . self::e($cap) . '</figcaption>' : '') . '</figure>';
            }

            case 'post-grid': {
                $cols = in_array((string) ($p['columns'] ?? '3'), ['2', '3'], true) ? (string) ($p['columns'] ?? '3') : '3';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-post-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                // Live posts: pull cards straight from a table (title/excerpt/image/
                // category/author/date columns), optionally linking each card to the
                // record's generated view page.
                if (self::ident($p['table'] ?? '') !== '') {
                    return self::postGridDbPhp($p, $cols, $head);
                }
                $cards = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $media = trim((string) ($it['image'] ?? '')) !== '' ? '<img src="' . self::e((string) $it['image']) . '" alt="" loading="lazy">' : '<div class="ppd-c-post-ph">' . self::ic('bi-image') . '</div>';
                    $meta = implode(' · ', array_map([self::class, 'e'], array_filter([trim((string) ($it['author'] ?? '')), trim((string) ($it['date'] ?? ''))])));
                    $body = '<div class="ppd-c-post-body">'
                        . (trim((string) ($it['category'] ?? '')) !== '' ? '<span class="ppd-c-post-cat">' . self::e((string) $it['category']) . '</span>' : '')
                        . '<h3>' . self::e((string) ($it['title'] ?? '')) . '</h3>' . (trim((string) ($it['excerpt'] ?? '')) !== '' ? '<p>' . self::e((string) $it['excerpt']) . '</p>' : '')
                        . ($meta !== '' ? '<div class="ppd-c-post-meta">' . $meta . '</div>' : '') . '<span class="ppd-c-post-more">Read more' . self::ic('bi-arrow-right') . '</span></div>';
                    $card = '<div class="ppd-c-post-media">' . $media . '</div>' . $body;
                    $href = self::safeHref((string) ($it['href'] ?? ''));
                    $cards .= ($href !== '' && $href !== '#')
                        ? '<a class="ppd-c-post" href="' . self::e($href) . '">' . $card . '</a>'
                        : '<article class="ppd-c-post">' . $card . '</article>';
                }
                return '<section class="ppd-c-posts"><div class="ppd-c-post-wrap">' . $head . '<div class="ppd-c-post-grid cols-' . $cols . '">' . $cards . '</div></div></section>';
            }

            case 'checklist': {
                $cols = (string) ($p['columns'] ?? '1') === '2' ? '2' : '1';
                $heading = trim((string) ($p['heading'] ?? '')); $subhead = trim((string) ($p['subhead'] ?? ''));
                $head = ($heading !== '' || $subhead !== '') ? '<div class="ppd-c-check-head">' . ($heading !== '' ? '<h2>' . self::e($heading) . '</h2>' : '') . ($subhead !== '' ? '<p>' . self::e($subhead) . '</p>' : '') . '</div>' : '';
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $inc = filter_var($it['included'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                    $items .= '<li class="' . ($inc ? 'is-yes' : 'is-no') . '">' . self::ic($inc ? 'bi-check-circle-fill' : 'bi-x-circle') . '<span>' . self::e((string) ($it['text'] ?? '')) . '</span></li>';
                }
                return '<section class="ppd-c-checklist"><div class="ppd-c-check-wrap">' . $head . '<ul class="ppd-c-check-list cols-' . $cols . '">' . $items . '</ul></div></section>';
            }

            case 'link-list': {
                $v = in_array((string) ($p['variant'] ?? 'solid'), ['solid', 'outline', 'soft'], true) ? (string) ($p['variant'] ?? 'solid') : 'solid';
                $mw = self::cssLen((string) ($p['maxWidth'] ?? ''));
                $items = '';
                foreach (self::arr($p['items'] ?? []) as $l) {
                    $l = (array) $l;
                    $items .= '<a class="ppd-c-linkitem is-' . $v . '" href="' . self::e(self::safeHref((string) ($l['href'] ?? '#'))) . '">'
                        . (trim((string) ($l['icon'] ?? '')) !== '' ? self::ic((string) $l['icon']) : '') . '<span>' . self::e((string) ($l['label'] ?? 'Link')) . '</span></a>';
                }
                return '<div class="ppd-c-linklist"' . ($mw !== '' ? ' style="max-width:' . self::e($mw) . '"' : '') . '>' . $items . '</div>';
            }

            case 'icon':
                $size = (int) ($p['size'] ?? 32);
                return '<div class="ppd-icon-block" style="font-size:' . $size . 'px">' . self::ic((string) ($p['ico'] ?? 'bi-star-fill')) . '</div>';

            case 'video':
                return '<div class="ppd-media">' . self::ic('bi-play-circle') . '<span>Video</span></div>';
            case 'iframe':
                return '<div class="ppd-media">' . self::ic('bi-window') . '<span>' . self::e((string) ($p['src'] ?? 'Embedded page')) . '</span></div>';
            case 'custom-map': {
                $prov = ($p['provider'] ?? 'google') === 'osm' ? 'osm' : 'google';
                $lat = trim((string) ($p['lat'] ?? '')); $lng = trim((string) ($p['lng'] ?? ''));
                $addr = trim((string) ($p['address'] ?? ''));
                $zoom = max(1, min(20, (int) ($p['zoom'] ?? 13)));
                $marker = filter_var($p['marker'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $url = '';
                if ($prov === 'google') {
                    $q = $addr !== '' ? $addr : (($lat !== '' && $lng !== '') ? $lat . ',' . $lng : '');
                    if ($q !== '') $url = 'https://maps.google.com/maps?q=' . rawurlencode($q) . '&z=' . $zoom . '&t=' . (($p['mapType'] ?? '') === 'satellite' ? 'k' : 'm') . '&hl=en&output=embed';
                } elseif ($lat !== '' && $lng !== '' && is_numeric($lat) && is_numeric($lng)) {
                    $d = 0.5 * pow(2, 13 - $zoom);
                    $bbox = ((float) $lng - $d) . ',' . ((float) $lat - $d) . ',' . ((float) $lng + $d) . ',' . ((float) $lat + $d);
                    $url = 'https://www.openstreetmap.org/export/embed.html?bbox=' . rawurlencode($bbox) . '&layer=mapnik' . ($marker ? '&marker=' . rawurlencode($lat . ',' . $lng) : '');
                }
                $h = self::cssLen($p['height'] ?? ''); if ($h === '') $h = '320px';
                if ($url === '') return '<div class="ppd-media ppd-map" style="min-height:' . self::e($h) . '">' . self::ic('bi-geo-alt') . '<span>Set an address or coordinates</span></div>';
                $rounded = filter_var($p['rounded'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $border = filter_var($p['border'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $boxCls = 'ppd-map-box' . (!$rounded ? ' no-round' : '') . ($border ? ' has-border' : '');
                $cap = trim((string) ($p['caption'] ?? '')) !== '' ? '<div class="ppd-map-cap">' . self::e((string) $p['caption']) . '</div>' : '';
                return '<div class="' . $boxCls . '"><iframe class="ppd-map-embed" src="' . self::e($url) . '" style="height:' . self::e($h) . '" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe></div>' . $cap;
            }

            case 'html':
                // Raw HTML component — author-provided markup passes through.
                return (string) ($p['code'] ?? '');
            case 'code-snippet':
            case 'css-snippet':
            case 'js-snippet':
                $lang = ['code-snippet' => 'Code', 'css-snippet' => 'CSS', 'js-snippet' => 'JavaScript'][$type];
                return '<div class="ppd-code"><div class="ppd-code-h">' . self::ic('bi-code-slash') . '<span>' . $lang . '</span></div>'
                    . '<pre class="ppd-code-b">' . self::e((string) ($p['code'] ?? '')) . '</pre></div>';
            case 'php-snippet':
                // PHP snippet runs on the page (author-trusted, like Events code).
                return '<?php ' . self::stripPhpTags((string) ($p['code'] ?? '')) . ' ?>';

            case 'tabs': {
                // Interactive tabs: each child carries a `tab` index (set in the
                // Designer by dropping into that tab's pane); orphans fall into the
                // last tab. Switching is wired by a SELF-CONTAINED runtime (below),
                // so tabs work on every page — even lean ones that don't load app.js.
                $tabs = self::arr($p['tabs'] ?? ['Tab 1', 'Tab 2']);
                if ($tabs === []) $tabs = ['Tab 1'];
                $kids = array_values((array) ($c['children'] ?? []));
                $count = count($tabs);
                $barStyle = in_array($p['barStyle'] ?? 'underline', ['underline', 'pills', 'boxed', 'segmented'], true) ? (string) ($p['barStyle'] ?? 'underline') : 'underline';
                $align    = in_array($p['align'] ?? 'start', ['start', 'center', 'end', 'stretch'], true) ? (string) ($p['align'] ?? 'start') : 'start';
                $size     = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $vertical = filter_var($p['vertical'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $activeTab = (int) ($p['activeTab'] ?? 0);
                if ($activeTab < 0 || $activeTab >= $count) $activeTab = 0;
                $wrapStyle = '';
                if (($abg = self::cssColor($p['barBg'] ?? '')) !== '')      $wrapStyle .= '--pptab-barbg:' . $abg . ';';
                if (($ac  = self::cssColor($p['activeColor'] ?? '')) !== '') $wrapStyle .= '--pptab-active:' . $ac . ';';
                $bar = '';
                $panes = '';
                foreach ($tabs as $i => $t) {
                    // Each tab entry is a label string (legacy) or {label,icon,bg,color,padding}.
                    $td = is_array($t) ? $t : ['label' => (string) $t];
                    $label = (string) ($td['label'] ?? ('Tab ' . ($i + 1)));
                    $icon = trim((string) ($td['icon'] ?? ''));
                    $active = $i === $activeTab ? ' active' : '';
                    $bar .= '<button type="button" class="ppd-tab' . $active . '" data-pptab="' . $i . '">'
                        . ($icon !== '' ? self::ic($icon) . ' ' : '') . self::e($label) . '</button>';
                    $paneStyle = '';
                    foreach (['bg' => 'background', 'color' => 'color', 'padding' => 'padding'] as $k => $prop) {
                        $v = trim((string) ($td[$k] ?? ''));
                        if ($v !== '') $paneStyle .= $prop . ':' . str_replace(['"', '<', '>'], '', $v) . ';';
                    }
                    $isLast = ($i === $count - 1);
                    $paneKids = array_values(array_filter($kids, static function ($ch) use ($i, $isLast, $count): bool {
                        $ct = (int) ($ch['tab'] ?? 0);
                        return $ct === $i || ($isLast && $ct > $count - 1);
                    }));
                    $inner = $paneKids ? $renderChildren(['children' => $paneKids]) : '';
                    $panes .= '<div class="ppd-tabpane' . $active . '" data-pptabpane="' . $i . '"' . ($i === $activeTab ? '' : ' hidden')
                        . ($paneStyle !== '' ? ' style="' . self::e($paneStyle) . '"' : '') . '>' . $inner . '</div>';
                }
                $cls = 'ppd-c-tabs bar-' . $barStyle . ' align-' . $align . ' size-' . $size . ($vertical ? ' is-vertical' : '');
                return self::tabsRuntime() . '<div class="' . $cls . '" data-pptabs'
                    . ($wrapStyle !== '' ? ' style="' . self::e($wrapStyle) . '"' : '') . '>'
                    . '<div class="ppd-tabbar">' . $bar . '</div>' . $panes . '</div>';
            }

            case 'accordion':
                // Interactive collapsible: header toggles the body (app.js).
                $accOpen = ($p['open'] ?? true);
                $caret = $accOpen ? 'bi-chevron-down' : 'bi-chevron-right';
                return '<div class="ppd-accordion' . ($accOpen ? ' is-open' : '') . '">'
                    . '<button type="button" class="ppd-accordion-h" data-ppacc-toggle>'
                    . '<i class="bi ' . $caret . ' ppd-acc-caret"></i>'
                    . '<span>' . self::e((string) ($p['heading'] ?? 'Section title')) . '</span></button>'
                    . '<div class="ppd-accordion-b" data-ppacc-body' . ($accOpen ? '' : ' hidden') . '>' . $renderChildren($c) . '</div></div>';

            case 'modal':
                return '<div class="ppd-modal"><div class="ppd-modal-h"><strong>' . self::e((string) ($p['heading'] ?? 'Modal title'))
                    . '</strong>' . self::ic('bi-x-lg') . '</div><div class="ppd-modal-b">' . $renderChildren($c) . '</div></div>';
            case 'offcanvas': {
                // Real slide-out using the app's bundled Bootstrap: a trigger button
                // toggles a Bootstrap offcanvas holding the child components.
                $id = 'oc' . preg_replace('/[^A-Za-z0-9]/', '', (string) ($c['id'] ?? '')) ?: 'oc' . substr(md5(serialize($p)), 0, 8);
                $side = in_array($p['side'] ?? 'end', ['start', 'end', 'top', 'bottom'], true) ? (string) ($p['side'] ?? 'end') : 'end';
                $v = in_array($p['triggerVariant'] ?? 'primary', ['primary', 'danger'], true) ? ' ' . $p['triggerVariant'] : '';
                $tIcon = trim((string) ($p['triggerIcon'] ?? '')) !== '' ? self::ic((string) $p['triggerIcon']) : '';
                $tLabel = self::e((string) ($p['triggerLabel'] ?? 'Open panel'));
                $heading = self::e((string) ($p['heading'] ?? 'Panel'));
                $backdrop = filter_var($p['backdrop'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $scroll = filter_var($p['scroll'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $width = self::cssLen((string) ($p['width'] ?? ''));
                $styleAttr = $width !== '' ? ' style="' . (in_array($side, ['top', 'bottom'], true) ? 'height' : 'width') . ':' . self::e($width) . '"' : '';
                $trigger = '<button class="ppd-bg-btn' . $v . '" type="button" data-bs-toggle="offcanvas" data-bs-target="#' . $id . '">' . $tIcon . '<span>' . $tLabel . '</span></button>';
                $panel = '<div class="offcanvas offcanvas-' . $side . '" tabindex="-1" id="' . $id . '"'
                    . ($backdrop ? '' : ' data-bs-backdrop="false"') . ($scroll ? ' data-bs-scroll="true"' : '') . $styleAttr . '>'
                    . '<div class="offcanvas-header"><h5 class="offcanvas-title">' . $heading . '</h5>'
                    . '<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button></div>'
                    . '<div class="offcanvas-body">' . $renderChildren($c) . '</div></div>';
                return '<div class="ppd-oc">' . $trigger . $panel . '</div>';
            }

            case 'status-badge': {
                $tone = (string) ($p['tone'] ?? 'ok');
                $cls  = 'ppd-badge tone-' . $tone;
                $variant = (string) ($p['variant'] ?? 'soft');
                if ($variant === 'solid') $cls .= ' bdg-solid'; elseif ($variant === 'outline') $cls .= ' bdg-outline';
                $size = (string) ($p['size'] ?? 'md'); if ($size === 'sm') $cls .= ' bdg-sm'; elseif ($size === 'lg') $cls .= ' bdg-lg';
                if (($p['shape'] ?? 'pill') === 'square') $cls .= ' bdg-square';
                $sv = '';
                if (($v = self::cssColor($p['color'] ?? '')) !== '') $sv .= 'color:' . $v . ';';
                if (($v = self::cssColor($p['bgColor'] ?? '')) !== '') $sv .= 'background:' . $v . ';';
                if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $sv .= 'border-color:' . $v . ';';
                $svAttr  = $sv !== '' ? ' style="' . self::e($sv) . '"' : '';
                $showDot = filter_var($p['showDot'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $pulse   = filter_var($p['pulse'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $icoName = trim((string) ($p['icon'] ?? ''));
                $lead = $icoName !== '' ? self::ic($icoName) : ($showDot ? '<span class="dot' . ($pulse ? ' pulse' : '') . '"></span>' : '');
                $prelude = '';
                if (($p['source'] ?? 'manual') === 'dynamic' && trim((string) ($p['query'] ?? '')) !== '') {
                    $q = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $prelude = '<?php try{$__sb=(string)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($q, true) . ')) ?? "");}catch(\Throwable $__e){$__sb="";} ?>';
                    $textOut = '<?php echo htmlspecialchars($__sb); ?>';
                } else {
                    $textOut = self::e((string) ($p['text'] ?? 'Active'));
                }
                return $prelude . '<span class="' . $cls . '"' . $svAttr . '>' . $lead . '<span' . self::partAttr($p, 'text') . '>' . $textOut . '</span></span>';
            }

            case 'toast': {
                $tone = (string) ($p['tone'] ?? 'ok'); if (!in_array($tone, ['ok', 'warn', 'danger', 'info'], true)) $tone = 'ok';
                $auto = ['ok' => 'bi-check-circle', 'warn' => 'bi-exclamation-triangle', 'danger' => 'bi-x-octagon', 'info' => 'bi-info-circle'][$tone];
                $ico  = self::ic(trim((string) ($p['icon'] ?? '')) !== '' ? (string) $p['icon'] : $auto);
                $closable = filter_var($p['closable'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $close = $closable ? '<button type="button" class="ppd-toast-close">' . self::ic('bi-x') . '</button>' : '';
                $text = trim((string) ($p['text'] ?? ''));
                $cs = self::customBoxStyle($p);
                $card = '<div class="ppd-toast tone-' . $tone . '"' . ($cs !== '' ? ' style="' . self::e($cs) . '"' : '') . '>' . $ico
                    . '<div><strong' . self::partAttr($p, 'title') . '>' . self::e((string) ($p['title'] ?? 'Saved')) . '</strong>'
                    . ($text !== '' ? '<small' . self::partAttr($p, 'text') . '>' . self::e($text) . '</small>' : '') . '</div>' . $close . '</div>';
                $pos = (string) ($p['position'] ?? 'tr'); if (!in_array($pos, ['tr', 'tl', 'tc', 'br', 'bl', 'bc'], true)) $pos = 'tr';
                $dur = max(0, (int) ($p['duration'] ?? 4000));
                return '<div class="ppd-toastc pos-' . $pos . '" data-toast-duration="' . $dur . '">' . $card . '</div>' . self::toastScript();
            }

            case 'progress': {
                $variant = ($p['variant'] ?? 'bar') === 'ring' ? 'ring' : 'bar';
                $dyn     = ($p['source'] ?? 'manual') === 'dynamic';
                $label   = trim((string) ($p['label'] ?? ''));
                $showPct = filter_var($p['showPercent'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $thresholds = filter_var($p['thresholds'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $color = self::cssColor($p['color'] ?? '');
                $track = self::cssColor($p['trackColor'] ?? '');

                $prelude = '';
                if ($dyn && trim((string) ($p['query'] ?? '')) !== '') {
                    $q   = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $tot = max(0, (float) ($p['total'] ?? 0));
                    $prelude = '<?php try{$__pgv=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($q, true) . ')) ?: 0);}catch(\Throwable $__e){$__pgv=0;} '
                        . '$__pgt=' . $tot . '; $__pg=$__pgt>0?(int)max(0,min(100,round($__pgv/$__pgt*100))):(int)max(0,min(100,round($__pgv))); ?>';
                    $pctOut = '<?php echo $__pg; ?>';
                    $toneOut = $thresholds ? '<?php echo $__pg<34?"danger":($__pg<67?"warning":"success"); ?>' : '';
                    $ringFill = $thresholds ? '<?php echo $__pg<34?"var(--pp-danger)":($__pg<67?"var(--pp-warning)":"var(--pp-success)"); ?>' : '';
                } else {
                    $v = self::pct($p['value'] ?? 0);
                    $pctOut = (string) $v;
                    $toneOut = $thresholds ? ($v < 34 ? 'danger' : ($v < 67 ? 'warning' : 'success')) : '';
                    $ringFill = $thresholds ? ($v < 34 ? 'var(--pp-danger)' : ($v < 67 ? 'var(--pp-warning)' : 'var(--pp-success)')) : '';
                }

                if ($variant === 'ring') {
                    $sz = self::cssLen($p['ringSize'] ?? ''); $th = self::cssLen($p['thickness'] ?? '');
                    $fill = $ringFill !== '' ? $ringFill : $color;
                    $vars = '--ring-p:' . $pctOut . ';' . ($sz !== '' ? '--ring-sz:' . $sz . ';' : '') . ($th !== '' ? '--ring-th:' . $th . ';' : '')
                        . ($fill !== '' ? '--ring-fill:' . $fill . ';' : '') . ($track !== '' ? '--ring-track:' . $track . ';' : '');
                    $center = $showPct ? '<span class="ppd-progress-ring-txt">' . $pctOut . '%</span>' : '';
                    $rlbl = $label !== '' ? '<div class="ppd-progress-ring-lbl">' . self::e($label) . '</div>' : '';
                    return $prelude . '<div class="ppd-progress-ring-wrap"><div class="ppd-progress-ring" style="' . $vars . '">' . $center . '</div>' . $rlbl . '</div>';
                }

                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $rounded  = filter_var($p['rounded'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $striped  = filter_var($p['striped'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $animated = filter_var($p['animated'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $target   = ($p['target'] ?? '') !== '' ? max(0, min(100, (int) $p['target'])) : null;
                $barCls   = 'ppd-progress-bar' . ($toneOut !== '' ? ' tone-' . $toneOut : '') . ($striped ? ' is-striped' : '') . ($animated ? ' is-animated' : '');
                $barStyle = 'width:' . $pctOut . '%' . (($toneOut === '' && $color !== '') ? ';background-color:' . $color : '');
                $trackCls = 'ppd-progress pp-prog-' . $size . ($rounded ? '' : ' pp-prog-square');
                $trackStyle = $track !== '' ? ' style="background:' . self::e($track) . '"' : '';
                $tgt = $target !== null ? '<span class="ppd-progtgt" style="left:' . $target . '%"></span>' : '';
                $lblRow = ($label !== '' || $showPct)
                    ? '<div class="ppd-progress-lbl"><span' . self::partAttr($p, 'label') . '>' . self::e($label) . '</span>' . ($showPct ? '<span>' . $pctOut . '%</span>' : '') . '</div>' : '';
                return $prelude . '<div class="ppd-progress-wrap">' . $lblRow . '<div class="' . $trackCls . '"' . $trackStyle . '>' . $tgt . '<div class="' . $barCls . '" style="' . $barStyle . '"></div></div></div>';
            }

            case 'progress-card': {
                $dyn        = ($p['source'] ?? 'manual') === 'dynamic';
                $size       = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $thresholds = filter_var($p['thresholds'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $striped    = filter_var($p['striped'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $animated   = filter_var($p['animated'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $rounded    = filter_var($p['rounded'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showPct    = filter_var($p['showPercent'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $color      = self::cssColor($p['color'] ?? '');
                $target     = ($p['target'] ?? '') !== '' ? max(0, min(100, (int) $p['target'])) : null;

                // Percent — manual value, or a dynamic SQL query (SqlVars-expanded,
                // run once at render). With a Total, the query is treated as the
                // current count and the percent is current/total.
                $prelude = '';
                if ($dyn && trim((string) ($p['query'] ?? '')) !== '') {
                    $q = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $tot = max(0, (float) ($p['total'] ?? 0));
                    $prelude = '<?php try{$__pcc=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($q, true) . ')) ?: 0);}catch(\Throwable $__e){$__pcc=0;} '
                        . '$__pct=' . $tot . '; $__pc=$__pct>0?(int)max(0,min(100,round($__pcc/$__pct*100))):(int)max(0,min(100,round($__pcc))); ?>';
                    $pctOut = '<?php echo $__pc; ?>';
                    $toneOut = $thresholds ? '<?php echo $__pc<34?"danger":($__pc<67?"warning":"success"); ?>' : '';
                    if (filter_var($p['autoHint'] ?? false, FILTER_VALIDATE_BOOLEAN) && $tot > 0) {
                        $unit = self::e((string) ($p['hintUnit'] ?? ''));
                        $hintOut = '<?php echo (int) $__pcc; ?> of ' . (int) $tot . ($unit !== '' ? ' ' . $unit : '');
                    } else {
                        $hintOut = self::e((string) ($p['hint'] ?? ''));
                    }
                } else {
                    $v = self::pct($p['value'] ?? 0);
                    $pctOut = (string) $v;
                    $toneOut = $thresholds ? ($v < 34 ? 'danger' : ($v < 67 ? 'warning' : 'success')) : '';
                    $hintOut = self::e((string) ($p['hint'] ?? ''));
                }

                $barCls = 'ppd-progress-bar' . ($toneOut !== '' ? ' tone-' . $toneOut : '') . ($striped ? ' is-striped' : '') . ($animated ? ' is-animated' : '');
                $barStyle = 'width:' . $pctOut . '%' . (($toneOut === '' && $color !== '') ? ';background-color:' . $color : '');
                $trackCls = 'ppd-progress pp-prog-' . $size . ($rounded ? '' : ' pp-prog-square');
                $tgt = $target !== null ? '<span class="ppd-progtgt" style="left:' . $target . '%"></span>' : '';
                $pctLabel = $showPct ? '<span' . self::partAttr($p, 'value') . '>' . $pctOut . '%</span>' : '';

                return $prelude . '<div class="ppd-widget"><div class="ppd-widget-h"><strong' . self::partAttr($p, 'label') . '>' . self::e((string) ($p['label'] ?? 'Progress')) . '</strong>' . $pctLabel . '</div>'
                    . '<div class="' . $trackCls . '">' . $tgt . '<div class="' . $barCls . '" style="' . $barStyle . '"></div></div>'
                    . '<small' . self::partAttr($p, 'hint', 'ppd-widget-hint') . '>' . $hintOut . '</small></div>';
            }

            case 'rating': {
                $SYM = [
                    'star'   => ['bi-star-fill', 'bi-star-half', 'bi-star'],
                    'heart'  => ['bi-heart-fill', 'bi-heart-half', 'bi-heart'],
                    'circle' => ['bi-circle-fill', 'bi-circle-half', 'bi-circle'],
                    'square' => ['bi-square-fill', 'bi-square-half', 'bi-square'],
                ];
                $sym  = $SYM[(string) ($p['symbol'] ?? 'star')] ?? $SYM['star'];
                $half = filter_var($p['allowHalf'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $mx   = max(1, min(20, (int) ($p['max'] ?? 5)));
                $vars = '';
                if (($v = self::cssColor($p['color'] ?? '')) !== '') $vars .= '--rate-color:' . $v . ';';
                if (($v = self::cssColor($p['emptyColor'] ?? '')) !== '') $vars .= '--rate-empty:' . $v . ';';
                if (($v = self::cssLen($p['size'] ?? '')) !== '') $vars .= '--rate-size:' . $v . ';';
                $svAttr  = $vars !== '' ? ' style="' . self::e($vars) . '"' : '';
                $label   = trim((string) ($p['label'] ?? ''));
                $lbl     = $label !== '' ? '<span class="ppd-rating-lbl">' . self::e($label) . '</span>' : '';
                $showVal = filter_var($p['showValue'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $suffix  = self::e((string) ($p['valueSuffix'] ?? ''));

                if (($p['source'] ?? 'manual') === 'dynamic' && trim((string) ($p['query'] ?? '')) !== '') {
                    $q = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $on = '"' . $sym[0] . '"'; $hf = '"' . $sym[1] . '"'; $off = '"' . $sym[2] . '"';
                    $prelude = '<?php try{$__rv=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($q, true) . ')) ?: 0);}catch(\Throwable $__e){$__rv=0;} $__rv=max(0,min(' . $mx . ',$__rv)); ?>';
                    $loop = '<?php for($__i=1;$__i<=' . $mx . ';$__i++){ if($__rv>=$__i){$__ic=' . $on . ';$__c="is-on";} elseif(' . ($half ? 'true' : 'false') . ' && $__rv>=$__i-0.5){$__ic=' . $hf . ';$__c="is-on";} else {$__ic=' . $off . ';$__c="is-off";} echo "<i class=\"".$__c." bi ".$__ic."\"></i>"; } ?>';
                    $valSpan = $showVal ? '<span class="ppd-rating-val"><?php echo htmlspecialchars((string)(($__rv==(int)$__rv)?(int)$__rv:$__rv)); ?>' . $suffix . '</span>' : '';
                    return $prelude . '<span class="ppd-rating"' . $svAttr . '>' . $lbl . $loop . $valSpan . '</span>';
                }

                $val = (float) ($p['value'] ?? 0); $val = max(0, min($mx, $val));
                $s = '';
                for ($i = 1; $i <= $mx; $i++) {
                    if ($val >= $i) { $ic = $sym[0]; $c = 'is-on'; }
                    elseif ($half && $val >= $i - 0.5) { $ic = $sym[1]; $c = 'is-on'; }
                    else { $ic = $sym[2]; $c = 'is-off'; }
                    $s .= '<i class="' . $c . ' bi ' . $ic . '"></i>';
                }
                $valStr  = ($val == (int) $val) ? (string) (int) $val : (string) $val;
                $valSpan = $showVal ? '<span class="ppd-rating-val">' . self::e($valStr) . $suffix . '</span>' : '';
                return '<span class="ppd-rating"' . $svAttr . '>' . $lbl . $s . $valSpan . '</span>';
            }

            case 'avatar': {
                $cls = 'ppd-avatar';
                $shape = (string) ($p['shape'] ?? 'circle');
                if ($shape === 'rounded') $cls .= ' av-rounded'; elseif ($shape === 'square') $cls .= ' av-square';
                $size = (string) ($p['size'] ?? 'md'); if (in_array($size, ['sm', 'lg', 'xl'], true)) $cls .= ' av-' . $size;
                $sv = '';
                if (($v = self::cssColor($p['bgColor'] ?? '')) !== '') $sv .= '--av-bg:' . $v . ';';
                if (($v = self::cssColor($p['fgColor'] ?? '')) !== '') $sv .= '--av-color:' . $v . ';';
                if (($v = self::cssColor($p['ringColor'] ?? '')) !== '') $sv .= '--av-ring:' . $v . ';';
                $svAttr = $sv !== '' ? ' style="' . self::e($sv) . '"' : '';
                $status = (string) ($p['status'] ?? 'none');
                $dot = in_array($status, ['online', 'away', 'busy', 'offline'], true) ? '<span class="ppd-avatar-dot st-' . $status . '"></span>' : '';
                $showText = filter_var($p['showText'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $openW = '<div class="' . $cls . '"' . $svAttr . '><span class="ppd-avatar-media"><span class="ppd-avatar-img">';
                $midW  = '</span>' . $dot . '</span>';

                // ---- Dynamic: pull one record's name / subtitle / photo ----
                if (($p['source'] ?? 'manual') === 'dynamic' && self::ident($p['table'] ?? '') !== '') {
                    $t  = self::ident($p['table']);
                    $nc = self::ident($p['nameColumn'] ?? '');
                    $sc = self::ident($p['subtitleColumn'] ?? '');
                    $ic = self::ident($p['imageColumn'] ?? '');
                    $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $sqlExpr = $where !== ''
                        ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . " LIMIT 1"'
                        : '"SELECT * FROM `' . $t . '` LIMIT 1"';
                    $nameE = $nc !== '' ? "htmlspecialchars((string)(\$__a['$nc'] ?? ''))" : "''";
                    $subE  = $sc !== '' ? "htmlspecialchars((string)(\$__a['$sc'] ?? ''))" : "''";
                    $mediaE = $ic !== ''
                        ? "((\$__iv=trim((string)(\$__a['$ic'] ?? ''))) !== '' ? '<img src=\"'.htmlspecialchars(\$__iv).'\" alt=\"\">' : htmlspecialchars(pp_initials((string)(\$__a['" . ($nc !== '' ? $nc : '') . "'] ?? ''))))"
                        : "htmlspecialchars(pp_initials((string)(\$__a['" . ($nc !== '' ? $nc : '') . "'] ?? '')))";
                    $textE = $showText ? "'<div class=\"ppd-avatar-text\"><strong>' . ($nameE) . '</strong>' . (((\$__sb=($subE)) !== '') ? '<small>'.\$__sb.'</small>' : '') . '</div>'" : "''";
                    $php = "<?php if(!function_exists('pp_initials')){function pp_initials(\$n){\$w=preg_split('/\\s+/',trim((string)\$n),-1,PREG_SPLIT_NO_EMPTY)?:[];if(!\$w)return '?';return strtoupper(mb_substr(\$w[0],0,1).(count(\$w)>1?mb_substr(\$w[count(\$w)-1],0,1):''));}}\n"
                        . "\$__ar = \\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: []; \$__a = \$__ar[0] ?? []; ?>"
                        . $openW . "<?php echo $mediaE; ?>" . $midW . "<?php echo $textE; ?></div>";
                    return $php;
                }

                // ---- Manual ----
                $at = (string) ($p['avatarType'] ?? 'initials');
                $nameRaw = (string) ($p['name'] ?? 'Jane Doe');
                $sub = (string) ($p['subtitle'] ?? '');
                if ($at === 'image') {
                    $iu = trim((string) ($p['imageUrl'] ?? ''));
                    $media = $iu !== '' ? '<img src="' . self::e($iu) . '" alt="' . self::e($nameRaw) . '">' : self::e(self::avatarInitials($nameRaw));
                } elseif ($at === 'icon') {
                    $media = self::ic((string) ($p['ico'] ?? 'bi-person'));
                } else {
                    $media = self::e(self::avatarInitials($nameRaw));
                }
                $text = $showText ? '<div class="ppd-avatar-text"><strong>' . self::e($nameRaw) . '</strong>' . ($sub !== '' ? '<small>' . self::e($sub) . '</small>' : '') . '</div>' : '';
                return $openW . $media . $midW . $text . '</div>';
            }

            case 'clock':
                // Standalone, fully-customisable analog clock (a droppable copy of
                // the hero dial). Static structure; the inline script below re-syncs
                // the hands + day/date/emoji to the VIEWER's local time on load.
                return self::clockWidget($p);

            case 'greeting':
                // Welcome hero.
                //
                // ⚠️ The greeting and the date are computed in the BROWSER, not
                // in PHP. The server runs in UTC, so date('G') greeted a user in
                // UTC+5:45 with "Good morning" at 14:45 local. PHP still renders
                // a sensible value so the markup is complete without JS (and for
                // crawlers); the inline script corrects it to local time.
                $gTitle = trim((string) ($p['title'] ?? ''));
                $gSub   = trim((string) ($p['subtitle'] ?? ''));
                $gName  = ($p['showName'] ?? true) !== false;
                $gDate  = ($p['showDate'] ?? true) !== false;
                $gUser  = ($p['showUser'] ?? true) !== false;

                $phpUser = '<?php $__u = (array) (\\PpPhp\\Core\\Security::CurrentUserData() ?: []);'
                    . ' $__n = trim((string) ($__u[\'fullname\'] ?? $__u[\'name\'] ?? $__u[\'username\'] ?? \'\')); ?>';
                $phpGreet = '<?php $__h = (int) date(\'G\');'
                    . ' echo htmlspecialchars($__h < 5 ? \'Good night\' : ($__h < 12 ? \'Good morning\' : ($__h < 17 ? \'Good afternoon\' : ($__h < 21 ? \'Good evening\' : \'Good night\')))); ?>';

                $head = $gTitle !== ''
                    ? '<span class="pp-greet">' . self::e($gTitle) . '</span>'
                    : '<span class="pp-greet" data-pp-greet>' . $phpGreet . '</span>'
                      . ($gName ? '<span class="pp-greet-name"><?= $__n !== \'\' ? \', \' . htmlspecialchars($__n) : \'\' ?></span>' : '');

                $meta = $gDate
                    ? '<div class="pp-hero-meta"><span' . self::partAttr($p, 'date', 'pp-hero-date') . ' data-pp-today>'
                      . '<i class="bi bi-calendar3"></i> <span><?= htmlspecialchars(date(\'l, j F Y\')) ?></span></span></div>'
                    : '';

                // Animated illustration — a small stack of "cards" that drift and
                // a pulsing orb behind them. Inline SVG so it ships with no asset
                // and inherits the theme through currentColor.
                // Right-hand visual: analog clock, abstract illustration, or none.
                $gArt = (string) ($p['art'] ?? 'clock');
                if (!in_array($gArt, ['clock', 'illustration', 'none'], true)) $gArt = 'clock';
                $art = $gArt === 'clock' ? self::heroClock($p) : ($gArt === 'illustration' ? self::heroArt() : '');

                return $phpUser . '<section class="pp-hero">'
                    . '<div class="pp-hero-in">'
                    . '<h1' . self::partAttr($p, 'title', 'pp-hero-title') . '>' . $head . '</h1>'
                    . ($gSub !== '' ? '<p' . self::partAttr($p, 'subtitle', 'pp-hero-sub') . '>' . self::e($gSub) . '</p>' : '')
                    . $meta
                    . '</div>'
                    . $art
                    . self::heroScript()
                    . '</section>';

            case 'stat-card': {
                $dyn = ($p['source'] ?? 'manual') === 'dynamic';
                $accent = self::cssColor($p['accent'] ?? '');
                // Card appearance — applied to the .ppd-stat element itself so
                // background / shadow / border actually style the visible card.
                $shadow = in_array($p['shadow'] ?? 'sm', ['none', 'sm', 'md', 'lg'], true) ? (string) ($p['shadow'] ?? 'sm') : 'sm';
                $vsize = in_array($p['valueSize'] ?? 'md', ['sm', 'md', 'lg', 'xl'], true) ? (string) ($p['valueSize'] ?? 'md') : 'md';
                $align = in_array($p['align'] ?? '', ['left', 'center', 'right'], true) ? (string) $p['align'] : '';
                $sty = '';
                if ($accent !== '') $sty .= '--stat-accent:' . $accent . ';';
                // background — solid, or a 2-colour gradient (bg → bg2)
                $bg1 = self::cssColor($p['bg'] ?? '');
                $bg2 = self::cssColor($p['bg2'] ?? '');
                if ($bg1 !== '' && $bg2 !== '') {
                    $dir = ['to-right' => 'to right', 'to-bottom' => 'to bottom', 'diagonal' => '135deg'][(string) ($p['bgDir'] ?? '')] ?? 'to bottom';
                    $sty .= 'background:linear-gradient(' . $dir . ',' . $bg1 . ',' . $bg2 . ');';
                } elseif ($bg1 !== '') {
                    $sty .= 'background:' . $bg1 . ';';
                }
                if (($v = self::cssColor($p['textColor'] ?? '')) !== '') $sty .= 'color:' . $v . ';';
                if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $sty .= 'border-color:' . $v . ';';
                if (($v = self::cssLen((string) ($p['radius'] ?? ''))) !== '') $sty .= 'border-radius:' . $v . ';';
                if (($v = self::cssLen((string) ($p['padding'] ?? ''))) !== '') $sty .= 'padding:' . $v . ';';
                if ($align !== '') $sty .= 'text-align:' . $align . ';';
                if (($v = self::css((string) ($p['font'] ?? ''))) !== '') $sty .= 'font-family:' . $v . ';';
                $statCls = 'ppd-stat stat-sh-' . $shadow . ' stat-v-' . $vsize . ($accent !== '' ? ' has-accent' : '')
                    . (filter_var($p['hover'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' is-hover' : '');
                $statStyle = $sty !== '' ? ' style="' . self::e($sty) . '"' : '';
                $t = self::ident($p['table'] ?? '');

                // Value: manual text, or a live aggregate (count/sum/avg/min/max) with
                // an optional SqlVars WHERE and number formatting.
                if ($dyn && $t !== '') {
                    $agg = strtolower((string) ($p['agg'] ?? 'count'));
                    if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
                    $col = self::ident($p['column'] ?? '');
                    $aggSql = ($agg === 'count' || $col === '') ? 'COUNT(*)' : strtoupper($agg) . '(`' . $col . '`)';
                    $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $sqlExpr = $where !== ''
                        ? '"SELECT ' . $aggSql . ' FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ')'
                        : '"SELECT ' . $aggSql . ' FROM `' . $t . '`"';
                    $dec = max(0, min(4, (int) ($p['decimals'] ?? 0)));
                    $abbr = filter_var($p['abbreviate'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                    $thou = filter_var($p['thousands'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                    $prefix = self::e((string) ($p['prefix'] ?? ''));
                    $suffix = self::e((string) ($p['suffix'] ?? ''));
                    $valueOut = self::ppKpiFmtHelper()
                        . '<?php try{$__sc=(float)(\PpPhp\Core\DB::FetchOne(' . $sqlExpr . ') ?: 0);}catch(\Throwable $__e){$__sc=0;} ?>'
                        . $prefix . '<?php echo pp_kpi_fmt($__sc,' . $dec . ',' . $abbr . ',' . $thou . '); ?>' . $suffix;
                } else {
                    $valueOut = self::e((string) ($p['value'] ?? '0'));
                }

                // value — part-styles + dedicated colour / font / weight
                $vBag = (array) (($p['partStyles'] ?? [])['value'] ?? []);
                if (($v = self::cssColor($p['valueColor'] ?? '')) !== '' && !isset($vBag['color'])) $vBag['color'] = $v;
                if (($p['valueFont'] ?? '') !== '' && !isset($vBag['font'])) $vBag['font'] = (string) $p['valueFont'];
                if (in_array((string) ($p['valueWeight'] ?? ''), ['normal', '500', '600', '700', '800'], true) && !isset($vBag['fontWeight'])) $vBag['fontWeight'] = (string) $p['valueWeight'];
                // icon — optional coloured badge + colour
                $iconBadge = filter_var($p['iconBadge'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $icBag = (array) (($p['partStyles'] ?? [])['icon'] ?? []);
                if (($v = self::cssColor($p['iconColor'] ?? '')) !== '' && !isset($icBag['color'])) $icBag['color'] = $v;
                if ($iconBadge && ($v = self::cssColor($p['iconBadgeColor'] ?? '')) !== '' && !isset($icBag['background'])) $icBag['background'] = $v;
                // caption — colour + optional uppercase
                $capBag = (array) (($p['partStyles'] ?? [])['caption'] ?? []);
                if (($v = self::cssColor($p['captionColor'] ?? '')) !== '' && !isset($capBag['color'])) $capBag['color'] = $v;
                $capUpper = filter_var($p['captionUpper'] ?? false, FILTER_VALIDATE_BOOLEAN);

                $card = '<div class="' . $statCls . '"' . $statStyle . '>'
                    . '<div' . self::partAttrFrom('v', $vBag) . '>' . $valueOut . '</div>'
                    . '<div' . self::partAttrFrom('l' . ($capUpper ? ' is-upper' : ''), $capBag) . '><span' . self::partAttrFrom('ppd-elicon' . ($iconBadge ? ' is-badge' : ''), $icBag) . '>'
                    . self::ic((string) ($p['icon'] ?? 'bi-database')) . '</span> ' . self::e((string) ($p['label'] ?? $label)) . '</div></div>';

                // Optional click-through to the bound table's list.
                if ($dyn && $t !== '' && filter_var($p['clickable'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                    $card = '<a class="ppd-stat-link" href="<?= APP_BASE ?>/generated/' . $t . '/list.php">' . $card . '</a>';
                }
                return '<div class="ppd-stat-cards">' . $card . '</div>';
            }

            case 'kpi-stat': {
                // Data-bound KPI tile: live aggregate (kpiValuePhp) + full card/value
                // styling — background, accent, shadow, border, radius, padding, align,
                // card font, and per-value colour / size / font / weight.
                $accent = self::cssColor($p['accent'] ?? '');
                $shadow = in_array($p['shadow'] ?? 'sm', ['none', 'sm', 'md', 'lg'], true) ? (string) ($p['shadow'] ?? 'sm') : 'sm';
                $vsize = in_array($p['valueSize'] ?? 'lg', ['sm', 'md', 'lg', 'xl'], true) ? (string) ($p['valueSize'] ?? 'lg') : 'lg';
                $align = in_array($p['align'] ?? '', ['left', 'center', 'right'], true) ? (string) $p['align'] : '';
                $sty = '';
                if ($accent !== '') $sty .= '--stat-accent:' . $accent . ';';
                // background — solid, or a 2-colour gradient (bg → bg2)
                $bg1 = self::cssColor($p['bg'] ?? '');
                $bg2 = self::cssColor($p['bg2'] ?? '');
                if ($bg1 !== '' && $bg2 !== '') {
                    $dir = ['to-right' => 'to right', 'to-bottom' => 'to bottom', 'diagonal' => '135deg'][(string) ($p['bgDir'] ?? '')] ?? 'to bottom';
                    $sty .= 'background:linear-gradient(' . $dir . ',' . $bg1 . ',' . $bg2 . ');';
                } elseif ($bg1 !== '') {
                    $sty .= 'background:' . $bg1 . ';';
                }
                if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $sty .= 'border-color:' . $v . ';';
                if (($v = self::cssLen((string) ($p['radius'] ?? ''))) !== '') $sty .= 'border-radius:' . $v . ';';
                if (($v = self::cssLen((string) ($p['padding'] ?? ''))) !== '') $sty .= 'padding:' . $v . ';';
                if ($align !== '') $sty .= 'text-align:' . $align . ';';
                if (($v = self::css((string) ($p['font'] ?? ''))) !== '') $sty .= 'font-family:' . $v . ';';
                $statCls = 'ppd-stat stat-sh-' . $shadow . ' stat-v-' . $vsize . ($accent !== '' ? ' has-accent' : '')
                    . (filter_var($p['hover'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' is-hover' : '');
                $statStyle = $sty !== '' ? ' style="' . self::e($sty) . '"' : '';

                $valueHtml = self::kpiValuePhp($p) ?: '&mdash;';
                // value element: part-styles + dedicated colour / font / weight props
                $vBag = (array) (($p['partStyles'] ?? [])['value'] ?? []);
                $valColor = (string) ($p['valueColor'] ?? $p['color'] ?? '');   // valueColor, else legacy `color`
                if ($valColor !== '' && !isset($vBag['color'])) $vBag['color'] = $valColor;
                if (($p['valueFont'] ?? '') !== '' && !isset($vBag['font'])) $vBag['font'] = (string) $p['valueFont'];
                if (in_array((string) ($p['valueWeight'] ?? ''), ['normal', '500', '600', '700', '800'], true) && !isset($vBag['fontWeight'])) $vBag['fontWeight'] = (string) $p['valueWeight'];
                // icon — optional coloured badge + icon colour
                $iconBadge = filter_var($p['iconBadge'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $icBag = (array) (($p['partStyles'] ?? [])['icon'] ?? []);
                if (($v = self::cssColor($p['iconColor'] ?? '')) !== '' && !isset($icBag['color'])) $icBag['color'] = $v;
                if ($iconBadge && ($v = self::cssColor($p['iconBadgeColor'] ?? '')) !== '' && !isset($icBag['background'])) $icBag['background'] = $v;
                // caption — colour + optional uppercase
                $capBag = (array) (($p['partStyles'] ?? [])['caption'] ?? []);
                if (($v = self::cssColor($p['captionColor'] ?? '')) !== '' && !isset($capBag['color'])) $capBag['color'] = $v;
                $capUpper = filter_var($p['captionUpper'] ?? false, FILTER_VALIDATE_BOOLEAN);

                return '<div class="ppd-stat-cards"><div class="' . $statCls . '"' . $statStyle . '>'
                    . '<div' . self::partAttrFrom('v', $vBag) . '>'
                    . self::e((string) ($p['prefix'] ?? '')) . $valueHtml . self::e((string) ($p['suffix'] ?? '')) . '</div>'
                    . '<div' . self::partAttrFrom('l' . ($capUpper ? ' is-upper' : ''), $capBag) . '><span' . self::partAttrFrom('ppd-elicon' . ($iconBadge ? ' is-badge' : ''), $icBag) . '>' . self::ic((string) ($p['icon'] ?? 'bi-database')) . '</span> ' . self::tableCaptionOrText($p, (string) ($p['label'] ?? ($label !== '' ? $label : 'KPI'))) . '</div></div></div>';
            }

            case 'chart':
                // Data-bound chart: an empty container the client fills from
                // api/chart.php (id-whitelisted registry) with an SVG chart.
                // Drill-down: clicking a bar/slice opens the source table's
                // list page filtered to that group (f[labelColumn]=label).
                $cid = (string) ($c['id'] ?? '');
                $drillAttrs = '';
                $dt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
                $dl = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['labelColumn'] ?? ''));
                if (filter_var($p['drill'] ?? false, FILTER_VALIDATE_BOOLEAN) && $dt !== '' && $dl !== '') {
                    $drillAttrs = ' data-pp-chartdrilltable="' . $dt . '" data-pp-chartdrillcol="' . $dl . '"';
                }
                return '<div class="pp-chart" data-pp-chart="' . self::e($cid) . '"'
                    . ' data-pp-charttype="' . self::e((string) ($p['chartType'] ?? 'bar')) . '"'
                    . ' data-pp-chartheight="' . (int) ($p['height'] ?? 260) . '"'
                    . ' data-pp-charttitle="' . self::t((string) ($p['title'] ?? '')) . '"'
                    . ' data-pp-chartsubtitle="' . self::t((string) ($p['subtitle'] ?? '')) . '"'
                    . ' data-pp-chartcolor="' . self::e((string) ($p['color'] ?? '')) . '"'
                    . ' data-pp-chartshowvalues="' . (filter_var($p['showValues'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                    . ' data-pp-chartshowgrid="' . (filter_var($p['showGrid'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false ? '1' : '0') . '"'
                    . ' data-pp-chartshowlegend="' . (filter_var($p['showLegend'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false ? '1' : '0') . '"'
                    . ' data-pp-chartcolorful="' . (filter_var($p['colorful'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                    . ' data-pp-chartsmooth="' . (filter_var($p['smooth'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                    . ' data-pp-chartdonuttotal="' . (filter_var($p['donutTotal'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                    . ' data-pp-chartanimate="' . (filter_var($p['animate'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false ? '1' : '0') . '"'
                    . ' data-pp-chartdepth="' . (filter_var($p['depth'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                    . ' data-pp-chartprefix="' . self::e((string) ($p['prefix'] ?? '')) . '"'
                    . ' data-pp-chartsuffix="' . self::e((string) ($p['suffix'] ?? '')) . '"'
                    . ' data-pp-chartabbrev="' . (filter_var($p['abbreviate'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"' . $drillAttrs
                    . ' style="border:1px solid var(--pp-border,#e2e8f0);border-radius:12px;background:var(--pp-surface,#fff);padding:14px;min-height:80px;"></div>';

            case 'kpi-row':
                // Each tile may be data-bound (table/agg → live aggregate at
                // runtime, same rules as kpi-stat) or carry a static value.
                $items = self::arr($p['items'] ?? []);
                $cards = '';
                foreach ($items as $it) {
                    $it = (array) $it;
                    $live = self::kpiValuePhp($it);
                    $val = $live !== ''
                        ? self::e((string) ($it['prefix'] ?? '')) . $live . self::e((string) ($it['suffix'] ?? ''))
                        : self::e((string) ($it['value'] ?? ''));
                    $vs = (string) ($it['color'] ?? '') !== '' ? ' style="color:' . self::e((string) $it['color']) . ';"' : '';
                    $cards .= '<div class="ppd-stat"><div class="v"' . $vs . '>' . $val . '</div>'
                        . '<div class="l">' . self::ic((string) ($it['icon'] ?? 'bi-dot')) . ' ' . self::e((string) ($it['label'] ?? '')) . '</div></div>';
                }
                return '<div class="ppd-stat-cards">' . $cards . '</div>';

            case 'detail-grid':
                // Master–Detail: an embedded child grid on the master's
                // view/edit page, filtered by detail.fkColumn = master.masterKey.
                // Rows render through the runtime's renderTable (lookups,
                // actions, consistent grid styling); the Add button pre-fills
                // the FK via the add page's URL-prefill; "View all" rides the
                // f[] filter pipeline. Identifiers whitelisted at BUILD time.
                $dt = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
                $fk = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['fkColumn'] ?? ''));
                $mk = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['masterKey'] ?? ''));
                if ($mk === '') $mk = 'id';
                if ($dt === '' || $fk === '') {
                    return '<div class="ppd-note">' . self::ic('bi-diagram-3')
                        . ' Configure the Detail grid: pick a detail table and its FK column.</div>';
                }
                // Section heading: a designer-set custom title stays literal;
                // the DEFAULT (humanized table name) is routed through the runtime
                // table-caption dictionary so it follows the Misc caption + active
                // language (e.g. case_hearings → "पेशीहरू"), matching every other
                // table caption in the generated app.
                $mdHuman = ucwords(str_replace(['-', '_'], ' ', $dt));
                $mdTitleHtml = trim((string) ($p['title'] ?? '')) !== ''
                    ? self::e((string) $p['title'])
                    : "<?= htmlspecialchars(function_exists('pp_table_caption') ? pp_table_caption('" . $dt . "', " . var_export($mdHuman, true) . ") : " . var_export($mdHuman, true) . ") ?>";
                $mdLimit = max(1, min(100, (int) ($p['pageSize'] ?? 10)));
                $mdDir = strtoupper((string) ($p['orderDir'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
                $mdCols = array_values(array_filter(array_map(
                    static fn ($s) => preg_replace('/[^A-Za-z0-9_]/', '', trim((string) $s)),
                    explode(',', (string) ($p['columns'] ?? ''))
                ), 'strlen'));
                // Rich designed columns (ordered; label/visible/align/width) —
                // from the Designer's per-column editor. Wins over the CSV.
                $mdColsCfg = [];
                foreach ((array) ($p['columnsCfg'] ?? []) as $cc) {
                    $cc = (array) $cc;
                    if (($cc['visible'] ?? true) === false) continue;
                    $cf = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($cc['field'] ?? ''));
                    if ($cf === '') continue;
                    $mdColsCfg[] = [
                        'field' => $cf,
                        'label' => trim((string) ($cc['label'] ?? '')),
                        'align' => in_array($cc['align'] ?? '', ['center', 'right'], true) ? $cc['align'] : '',
                        'width' => self::cssLen($cc['width'] ?? ''),
                    ];
                }
                $mdLk = [];
                if (self::$detailLookups !== null) {
                    foreach ((array) call_user_func(self::$detailLookups, $dt) as $lname => $ldef) {
                        $mdLk[(string) $lname] = [
                            'table'   => (string) (((array) $ldef)['table'] ?? ''),
                            'key'     => (string) (((array) $ldef)['key'] ?? 'id'),
                            'display' => (string) (((array) $ldef)['display'] ?? ''),
                        ];
                    }
                }
                $bAdd  = filter_var($p['allowAdd'] ?? true, FILTER_VALIDATE_BOOLEAN);
                $bAll  = filter_var($p['showViewAll'] ?? true, FILTER_VALIDATE_BOOLEAN);
                // Child table's Pages functionality: inline edit / spreadsheet
                // replace the navigation actions; inline add replaces the
                // FK-prefilled add link.
                $mdMeta = self::$detailMeta !== null ? (array) call_user_func(self::$detailMeta, $dt) : [];
                $mdInlineEdit = (!empty($mdMeta['inlineEdit']) || !empty($mdMeta['spreadsheet']))
                    && filter_var($p['allowEdit'] ?? true, FILTER_VALIDATE_BOOLEAN);
                $mdInlineAdd = !empty($mdMeta['inlineAdd']) && $bAdd;
                $mdSheet = !empty($mdMeta['spreadsheet']);
                $acts = $mdInlineEdit
                    ? var_export([
                        'show'  => true,
                        'items' => ['inline-edit' => ['icon' => 'bi-pencil-square', 'label' => 'Edit inline', 'display' => 'icon']],
                    ], true)
                    : var_export([
                        'show'   => true,
                        'view'   => filter_var($p['allowView'] ?? true, FILTER_VALIDATE_BOOLEAN),
                        'edit'   => filter_var($p['allowEdit'] ?? true, FILTER_VALIDATE_BOOLEAN),
                        'delete' => filter_var($p['allowDelete'] ?? false, FILTER_VALIDATE_BOOLEAN),
                    ], true);
                $v = '$__md' . substr(md5((string) ($c['id'] ?? $dt . $fk)), 0, 6);
                $php = '<?php'
                    . "\n{$v}K = (string) (\$row['$mk'] ?? '');"
                    . "\n{$v}Rows = {$v}K === '' ? [] : (\\PpPhp\\Core\\DB::FetchAll(\"SELECT * FROM `$dt` WHERE `$fk` = ? ORDER BY `\" . (function_exists('pp_pk') ? pp_pk('$dt') : 'id') . \"` $mdDir LIMIT $mdLimit\", [{$v}K]) ?: []);"
                    . "\nif (function_exists('pp_with_id')) pp_with_id({$v}Rows, '$dt');"
                    . "\n{$v}N = {$v}K === '' ? 0 : (int) \\PpPhp\\Core\\DB::FetchOne(\"SELECT COUNT(*) FROM `$dt` WHERE `$fk` = ?\", [{$v}K]);"
                    . "\n{$v}Cols = " . var_export($mdCols, true) . ';'
                    . ($mdColsCfg !== []
                        ? "\n{$v}Cols = " . var_export(array_map(static fn ($c) => $c['field'], $mdColsCfg), true) . ';'
                        : "\nif ({$v}Cols === []) {$v}Cols = array_slice(array_values(array_filter(array_keys({$v}Rows[0] ?? []), static fn (\$c) => \$c !== '$fk')), 0, 6);")
                    . "\n{$v}Lk = " . var_export($mdLk, true) . ';'
                    . ($mdColsCfg !== []
                        ? "\n{$v}Fields = array_map(static fn (\$c) => \$c + (isset({$v}Lk[\$c['name']]) ? ['lookup' => {$v}Lk[\$c['name']]] : []), " . var_export(array_map(static function ($c) {
                            $cellCss = ($c['align'] !== '' ? 'text-align:' . $c['align'] . ';' : '') . ($c['width'] !== '' ? 'width:' . $c['width'] . ';' : '');
                            return ['name' => $c['field'],
                                'label' => $c['label'] !== '' ? $c['label'] : ucwords(str_replace('_', ' ', $c['field'])),
                                'type' => 'text', 'sortable' => false]
                                + ($cellCss !== '' ? ['thStyle' => $cellCss, 'tdStyle' => $cellCss] : []);
                        }, $mdColsCfg), true) . ');'
                        : "\n{$v}Fields = array_map(static fn (\$c) => ['name' => \$c, 'label' => ucwords(str_replace('_', ' ', \$c)), 'type' => 'text', 'sortable' => false] + (isset({$v}Lk[\$c]) ? ['lookup' => {$v}Lk[\$c]] : []), {$v}Cols);")
                    . ($mdInlineEdit || $mdSheet ? "\n{$v}Ed = array_values(array_filter({$v}Cols, static fn (\$c) => \$c !== 'id'));" : '')
                    . "\n?>";
                $hideEmpty = filter_var($p['hideIfEmpty'] ?? false, FILTER_VALIDATE_BOOLEAN);
                // Designer styling (Section / Header / Grid) — theme-aware base
                // from CSS classes; these are per-component overrides.
                $st = self::detailSectionCss($p);
                $gridCls = trim(($mdSheet ? 'pp-spreadsheet ' : '') . $st['gridClass']);
                $rtParams = "['selection' => false, 'actions' => $acts"
                    . ($mdInlineEdit || $mdSheet ? ", 'inlineMeta' => true, 'editableFields' => {$v}Ed" : '')
                    . ($mdInlineAdd ? ", 'inlineAdd' => true" : '')
                    . ($gridCls !== '' ? ", 'gridClass' => '" . $gridCls . "'" : '')
                    . ']';
                $fkAttrs = $mdInlineAdd ? " data-pp-fk=\"$fk\" data-pp-fkval=\"<?= htmlspecialchars({$v}K) ?>\"" : '';
                // Header components (title / badge / add / view-all): per-part
                // styling from the Designer's component editors.
                $btnCss = static function (string $bgKey, string $colKey) use ($p): string {
                    $css = '';
                    if (($c2 = self::cssColor($p[$bgKey] ?? '')) !== '') $css .= 'background:' . $c2 . ';border-color:' . $c2 . ';';
                    if (($c2 = self::cssColor($p[$colKey] ?? '')) !== '') $css .= 'color:' . $c2 . ';';
                    return $css;
                };
                $addCss = $btnCss('addBg', 'addColor');
                $goCss = $btnCss('goBg', 'goColor');
                $addLbl = trim((string) ($p['addLabel'] ?? ''));
                $goLbl = trim((string) ($p['goLabel'] ?? ''));
                $addIc = trim((string) ($p['addIcon'] ?? '')) !== '' ? (string) $p['addIcon'] : 'bi-plus-lg';
                $goIc = trim((string) ($p['goIcon'] ?? '')) !== '' ? (string) $p['goIcon'] : 'bi-box-arrow-up-right';
                $addBtn = '';
                if ($bAdd && !filter_var($p['addHide'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                    $addStyle = $addCss !== '' ? ' style="' . $addCss . '"' : '';
                    $addBtn = $mdInlineAdd
                        ? "<?php if ({$v}K !== ''): ?><button type=\"button\" class=\"ppd-grid-toolbar-btn pp-inline-add-btn\" data-pp-btn=\"add\"$addStyle>" . self::ic($addIc) . ' ' . ($addLbl !== '' ? self::e($addLbl) : "<?= pp_t('ui.inline_add', 'Inline Add') ?>") . '</button><?php endif; ?>'
                        : "<?php if ({$v}K !== ''): ?><a class=\"ppd-grid-toolbar-btn\" data-pp-btn=\"add\"$addStyle href=\"<?= APP_BASE ?>/generated/$dt/add.php?$fk=<?= urlencode({$v}K) ?>\">" . self::ic($addIc) . ' ' . ($addLbl !== '' ? self::e($addLbl) : "<?= pp_t('ui.add', 'Add') ?>") . '</a><?php endif; ?>';
                }
                $secAttr = $st['section'] !== '' ? ' style="' . $st['section'] . '"' : '';
                $headAttr = ($st['header'] !== '' || $st['headerColor'] !== '') ? ' style="' . $st['header'] . $st['headerColor'] . '"' : '';
                $titleCss = (($c2 = self::cssColor($p['titleColor'] ?? '')) !== '' ? 'color:' . $c2 . ';' : '')
                    . (filter_var($p['titleBold'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'font-weight:800;' : '');
                $badgeStyled = ($p['badgeBg'] ?? '') !== '' || ($p['badgeColor'] ?? '') !== ''
                    || ($p['badgeSize'] ?? '') !== '' || ($p['badgeShape'] ?? '') !== '';
                $badgeHtml = filter_var($p['badgeHide'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '' : ($badgeStyled
                    ? " <span class=\"badge\" style=\"" . self::badgeCss(
                        in_array($p['badgeSize'] ?? '', ['sm', 'md', 'lg'], true) ? $p['badgeSize'] : 'md',
                        in_array($p['badgeShape'] ?? '', ['pill', 'rounded', 'square', 'circle'], true) ? $p['badgeShape'] : 'pill',
                        (string) ($p['badgeBg'] ?? ''), (string) ($p['badgeColor'] ?? '')
                    ) . "\"><?= {$v}N ?></span>"
                    : " <span class=\"badge bg-secondary\"><?= {$v}N ?></span>");
                $titleHtml = $st['hideHeader'] ? '' : ('<strong' . ($titleCss !== '' ? ' style="' . $titleCss . '"' : '') . '>'
                    . self::ic((string) ($p['icon'] ?? 'bi-diagram-3')) . ' ' . $mdTitleHtml . '</strong>' . $badgeHtml);
                return $php
                    . ($hideEmpty ? "<?php if ({$v}N > 0): ?>" : '')
                    . '<div class="pp-detail-grid" data-pp-md-table="' . self::e($dt) . '" data-pp-md-fk="' . self::e($fk) . '"' . $fkAttrs . $secAttr . '>'
                    . '<div class="pp-md-head"' . $headAttr . '>'
                    . $titleHtml
                    . '<span style="flex:1"></span>'
                    . $addBtn
                    . ($bAll && !filter_var($p['goHide'] ?? false, FILTER_VALIDATE_BOOLEAN)
                        ? "<?php if ({$v}N > 0): ?><a class=\"ppd-grid-toolbar-btn\"" . ($goCss !== '' ? ' style="' . $goCss . '"' : '') . " href=\"<?= APP_BASE ?>/generated/$dt/list.php?f%5B$fk%5D=<?= urlencode({$v}K) ?>\">" . self::ic($goIc) . ' ' . ($goLbl !== '' ? self::e($goLbl) : "<?= pp_t('ui.view_all', 'View all') ?>") . '</a><?php endif; ?>'
                        : '')
                    . '</div>'
                    . "<?php if ({$v}K === ''): ?><div class=\"ppd-grid-empty\" style=\"padding:.5rem 0;color:var(--pp-muted,#94a3b8);\"><i class=\"bi bi-info-circle\"></i> Details can be added after this record is saved.</div>"
                    . "<?php else: echo renderTable({$v}Rows, {$v}Fields, '$dt', $rtParams); endif; ?>"
                    . '</div>'
                    . ($hideEmpty ? '<?php endif; ?>' : '');

            case 'kpi-trend-card': {
                $dyn = ($p['source'] ?? 'manual') === 'dynamic';
                $iconName = trim((string) ($p['icon'] ?? ''));
                $head = '<div class="ppd-widget-h"><small' . self::partAttr($p, 'label') . '>' . self::e((string) ($p['label'] ?? 'KPI')) . '</small>'
                    . ($iconName !== '' ? '<span class="ppd-kpi-ic">' . self::ic($iconName) . '</span>' : '') . '</div>';

                // Dynamic: value (and optionally delta) come from SQL, run once at
                // render via DB::FetchOne(SqlVars::expand()). A guarded pp_kpi_fmt()
                // helper formats the numbers (decimals / abbreviation / grouping).
                if ($dyn && trim((string) ($p['query'] ?? '')) !== '') {
                    $vq   = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $dec  = max(0, min(4, (int) ($p['decimals'] ?? 0)));
                    $abbr = filter_var($p['abbreviate'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                    $thou = filter_var($p['thousands'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                    $prefix = self::e((string) ($p['prefix'] ?? ''));
                    $suffix = self::e((string) ($p['suffix'] ?? ''));
                    $deltaMode = in_array($p['deltaMode'] ?? 'compare', ['compare', 'query', 'none'], true) ? (string) ($p['deltaMode'] ?? 'compare') : 'compare';

                    $pre = self::ppKpiFmtHelper() . '<?php ';
                    $pre .= 'try{$__kv=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($vq, true) . ')) ?: 0);}catch(\Throwable $__e){$__kv=0;} ';
                    $deltaIsPct = false;
                    $hasDeltaCfg = false;
                    if ($deltaMode === 'compare' && trim((string) ($p['compareQuery'] ?? '')) !== '') {
                        $cq = trim(str_replace(["\0", ';'], '', (string) $p['compareQuery']));
                        $pre .= 'try{$__kp=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($cq, true) . ')) ?: 0);}catch(\Throwable $__e){$__kp=0;} ';
                        $pre .= '$__kd=$__kp!=0?(($__kv-$__kp)/abs($__kp)*100):0; $__khas=($__kp!=0); ';
                        $deltaIsPct = true;
                        $hasDeltaCfg = true;
                    } elseif ($deltaMode === 'query' && trim((string) ($p['deltaQuery'] ?? '')) !== '') {
                        $dq = trim(str_replace(["\0", ';'], '', (string) $p['deltaQuery']));
                        $pre .= 'try{$__kd=(float)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($dq, true) . ')) ?: 0);}catch(\Throwable $__e){$__kd=0;} $__khas=true; ';
                        $hasDeltaCfg = true;
                    } else {
                        $pre .= '$__kd=0; $__khas=false; ';
                    }
                    $pre .= '$__kt=$__kd>0?"up":($__kd<0?"down":"flat"); ';   // greater→up, less→down (data-driven)
                    $pre .= '$__kcolor="tr-".$__kt; ';
                    $pre .= '$__karrow=($__kt=="down")?"bi-arrow-down-right":(($__kt=="flat")?"bi-arrow-right":"bi-arrow-up-right"); ';
                    $pre .= '?>';

                    $valueOut = $prefix . '<?php echo pp_kpi_fmt($__kv,' . $dec . ',' . $abbr . ',' . $thou . '); ?>' . $suffix;

                    // Delta element built by hand so the PHP-computed colour class can be
                    // injected (partAttr would HTML-escape an embedded PHP echo tag).
                    // User part-styles for the delta are preserved.
                    $deltaEl = '';
                    if ($hasDeltaCfg) {
                        $deltaBag = (array) (($p['partStyles'] ?? [])['delta'] ?? []);
                        $duser = trim((string) preg_replace('/[^a-zA-Z0-9_ -]/', '', (string) ($deltaBag['class'] ?? '')));
                        $dcss  = self::partStyleCss($deltaBag);
                        $dClsAttr = ' class="ppd-kpi-delta <?php echo $__kcolor; ?>' . ($duser !== '' ? ' ' . self::e($duser) : '') . '"'
                            . ($dcss !== '' ? ' style="' . self::e($dcss) . '"' : '');
                        $deltaTxt = $deltaIsPct
                            ? '<?php echo ($__kd>0?"+":"").number_format($__kd,1)."%"; ?>'
                            : ($prefix . '<?php echo ($__kd>0?"+":"").pp_kpi_fmt($__kd,' . $dec . ',' . $abbr . ',' . $thou . '); ?>' . $suffix);
                        $deltaEl = '<?php if($__khas): ?><div' . $dClsAttr . '><i class="bi <?php echo $__karrow; ?>"></i>' . $deltaTxt . '</div><?php endif; ?>';
                    }

                    return $pre . '<div class="ppd-widget">' . $head
                        . '<div' . self::partAttr($p, 'value', 'ppd-kpi-value') . '>' . $valueOut . '</div>'
                        . $deltaEl . '</div>';
                }

                // Manual
                $tr = (string) ($p['trend'] ?? 'up');
                $trIc = $tr === 'down' ? 'bi-arrow-down-right' : ($tr === 'flat' ? 'bi-arrow-right' : 'bi-arrow-up-right');
                $deltaTxt = (string) ($p['delta'] ?? '');
                $deltaEl = $deltaTxt !== ''
                    ? '<div' . self::partAttr($p, 'delta', 'ppd-kpi-delta tr-' . self::e($tr)) . '>' . self::ic($trIc) . self::e($deltaTxt) . '</div>'
                    : '';
                return '<div class="ppd-widget">' . $head
                    . '<div' . self::partAttr($p, 'value', 'ppd-kpi-value') . '>' . self::e((string) ($p['value'] ?? '0')) . '</div>'
                    . $deltaEl . '</div>';
            }

            case 'timeline': {
                $items = self::arr($p['items'] ?? []);
                $isDyn = ($p['source'] ?? 'manual') === 'dynamic';
                // Legacy pages stored only `count` — synthesize placeholder rows.
                if (!$isDyn && !$items && (int) ($p['count'] ?? 0) > 0) {
                    $cnt = max(1, min(8, (int) $p['count']));
                    for ($i = 1; $i <= $cnt; $i++) $items[] = ['title' => 'Milestone ' . $i, 'text' => 'Description of this event'];
                }
                // Component-level typography (applies to every event) — sanitized.
                $tS = '';
                if (($v = self::css((string) ($p['titleFont'] ?? ''))) !== '') $tS .= 'font-family:' . $v . ';';
                if (($v = self::cssLen($p['titleSize'] ?? '')) !== '') $tS .= 'font-size:' . $v . ';';
                if (in_array((string) ($p['titleWeight'] ?? ''), ['normal', '500', '600', '700', '800'], true)) $tS .= 'font-weight:' . $p['titleWeight'] . ';';
                if (($v = self::cssColor($p['titleColor'] ?? '')) !== '') $tS .= 'color:' . $v . ';';
                $dS = '';
                if (($v = self::css((string) ($p['textFont'] ?? ''))) !== '') $dS .= 'font-family:' . $v . ';';
                if (($v = self::cssLen($p['textSize'] ?? '')) !== '') $dS .= 'font-size:' . $v . ';';
                if (($v = self::cssColor($p['textColor'] ?? '')) !== '') $dS .= 'color:' . $v . ';';
                $dotSize   = self::cssLen($p['dotSize'] ?? '');
                $iconColor = self::cssColor($p['iconColor'] ?? '');
                $tAttr = $tS !== '' ? ' style="' . self::e($tS) . '"' : '';
                $dAttr = $dS !== '' ? ' style="' . self::e($dS) . '"' : '';

                // Container class + line vars (layout / marker / spacing / card / line).
                $cls = 'ppd-timeline';
                $layout = (string) ($p['layout'] ?? 'left');
                if ($layout === 'right') $cls .= ' tl-right'; elseif ($layout === 'alternate') $cls .= ' tl-alt';
                $mk = (string) ($p['marker'] ?? 'filled');
                if ($mk === 'outline') $cls .= ' mk-outline'; elseif ($mk === 'ring') $cls .= ' mk-ring';
                if (filter_var($p['cardStyle'] ?? false, FILTER_VALIDATE_BOOLEAN)) $cls .= ' tl-card';
                $sp = (string) ($p['spacing'] ?? 'normal');
                if ($sp === 'compact') $cls .= ' sp-compact'; elseif ($sp === 'relaxed') $cls .= ' sp-relaxed';
                $lv = '';
                if (($v = self::cssColor($p['lineColor'] ?? '')) !== '') $lv .= '--tl-lc:' . $v . ';';
                if (($v = self::cssLen($p['lineWidth'] ?? '')) !== '') $lv .= '--tl-lw:' . $v . ';';
                if (in_array((string) ($p['lineStyle'] ?? ''), ['solid', 'dashed', 'dotted'], true)) $lv .= '--tl-ls:' . $p['lineStyle'] . ';';
                $openAttr = $lv !== '' ? ' style="' . self::e($lv) . '"' : '';
                $showDate = filter_var($p['showDate'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;

                // ---- Dynamic: events come from a table (WHERE via SqlVars at runtime). ----
                if ($isDyn && self::ident($p['table'] ?? '') !== '') {
                    $t   = self::ident($p['table']);
                    $tc  = self::ident($p['titleColumn'] ?? '');
                    $xc  = self::ident($p['textColumn'] ?? '');
                    $dc  = self::ident($p['dateColumn'] ?? '');
                    $icc = self::ident($p['iconColumn'] ?? '');
                    $ob  = self::ident($p['orderBy'] ?? '');
                    if ($ob === '') $ob = $dc;                       // default order = date column
                    $dir = strtolower((string) ($p['orderDir'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
                    $lim = max(1, min(50, (int) ($p['limit'] ?? 8)));
                    $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $empty = self::e((string) ($p['emptyText'] ?? 'No events yet'));
                    $order = $ob !== '' ? " ORDER BY `$ob` $dir" : '';
                    $sqlExpr = $where !== ''
                        ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . "' . $order . ' LIMIT ' . $lim . '"'
                        : '"SELECT * FROM `' . $t . '`' . $order . ' LIMIT ' . $lim . '"';

                    $dotConst = $dotSize !== '' ? ' style="width:' . self::e($dotSize) . ';height:' . self::e($dotSize) . ';"' : '';
                    $iconStyle = ($dotSize !== '' ? 'width:' . $dotSize . ';height:' . $dotSize . ';' : '') . ($iconColor !== '' ? 'color:' . $iconColor . ';' : '');
                    $iconAttr = $iconStyle !== '' ? ' style="' . self::e($iconStyle) . '"' : '';
                    $titleE = $tc !== '' ? "htmlspecialchars((string)(\$__r['$tc'] ?? ''))" : "''";
                    $textE  = $xc !== '' ? "htmlspecialchars((string)(\$__r['$xc'] ?? ''))" : "''";
                    $dateE  = ($showDate && $dc !== '')
                        ? "((\$__dv=trim((string)(\$__r['$dc'] ?? ''))) !== '' ? '<span class=\"ppd-tl-date\">' . htmlspecialchars(\$__dv) . '</span>' : '')"
                        : "''";
                    $dotE = $icc !== ''
                        ? "((\$__iv=trim((string)(\$__r['$icc'] ?? ''))) !== '' ? '<span class=\"ppd-tl-dot has-ic\"$iconAttr><i class=\"' . htmlspecialchars(\$__iv) . '\"></i></span>' : '<span class=\"ppd-tl-dot\"$dotConst></span>')"
                        : "'<span class=\"ppd-tl-dot\"$dotConst></span>'";

                    $php = "<?php try { \$__tl = \\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: []; } catch (\\Throwable \$__e) { \$__tl = []; } ?>";
                    $loop = "<?php if (!\$__tl) { echo '<div class=\"ppd-tl-empty\">$empty</div>'; }\n"
                        . "foreach (\$__tl as \$__r) {\n"
                        . "  echo '<div class=\"ppd-tl-row\">' . $dotE . '<div class=\"ppd-tl-c\">' . $dateE . '<strong$tAttr>' . $titleE . '</strong><small$dAttr>' . $textE . '</small></div></div>';\n"
                        . "} ?>";
                    return $php . '<div class="' . $cls . '"' . $openAttr . '>' . $loop . '</div>';
                }

                // ---- Manual events ----
                $rows = '';
                foreach ($items as $it) {
                    $it = (array) $it;
                    $dot = '';
                    $c = self::cssColor($it['color'] ?? '');
                    if ($c !== '') $dot .= 'background:' . $c . ';border-color:' . $c . ';';
                    if ($dotSize !== '') $dot .= 'width:' . $dotSize . ';height:' . $dotSize . ';';
                    $hasIcon = trim((string) ($it['icon'] ?? '')) !== '';
                    if ($iconColor !== '' && $hasIcon) $dot .= 'color:' . $iconColor . ';';
                    $dotAttr = $dot !== '' ? ' style="' . self::e($dot) . '"' : '';
                    $ic = $hasIcon
                        ? '<span class="ppd-tl-dot has-ic"' . $dotAttr . '>' . self::ic((string) $it['icon']) . '</span>'
                        : '<span class="ppd-tl-dot"' . $dotAttr . '></span>';
                    $date = ($showDate && trim((string) ($it['date'] ?? '')) !== '')
                        ? '<span class="ppd-tl-date">' . self::e((string) $it['date']) . '</span>' : '';
                    $body = '<div class="ppd-tl-c">' . $date
                        . '<strong' . $tAttr . '>' . self::e((string) ($it['title'] ?? '')) . '</strong>'
                        . '<small' . $dAttr . '>' . self::e((string) ($it['text'] ?? '')) . '</small></div>';
                    $link = trim((string) ($it['link'] ?? ''));
                    if ($link !== '' && !preg_match('/^\s*javascript:/i', $link)) {
                        $body = '<a class="ppd-tl-link" href="' . self::e($link) . '">' . $body . '</a>';
                    }
                    $rows .= '<div class="ppd-tl-row">' . $ic . $body . '</div>';
                }
                return '<div class="' . $cls . '"' . $openAttr . '>' . $rows . '</div>';
            }

            case 'quick-links':
                $items = self::arr($p['items'] ?? []);
                $links = '';
                foreach ($items as $l) {
                    $l = (array) $l;
                    // A quick link with a url becomes a real anchor. Without this
                    // the widget rendered dead <span>s, which is useless on a
                    // landing page whose whole job is navigation.
                    $url  = trim((string) ($l['url'] ?? $l['href'] ?? ''));
                    $tag  = $url !== '' ? 'a' : 'span';
                    $attr = $url !== '' ? ' href="' . self::appUrl($url) . '"' : '';
                    $links .= '<' . $tag . ' class="ppd-quicklink"' . $attr . '>'
                        . self::ic((string) ($l['icon'] ?? 'bi-link-45deg'))
                        . '<span>' . self::e((string) ($l['label'] ?? 'Link')) . '</span></' . $tag . '>';
                }
                // An explicitly blank heading means "no header bar" — otherwise
                // the widget renders an empty strip, which looks like a bug when
                // the links sit inside another card.
                $qh = array_key_exists('heading', $p) ? trim((string) $p['heading']) : 'Quick links';
                $qhHtml = $qh !== '' ? '<div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . self::e($qh) . '</strong></div>' : '';
                return '<div class="ppd-widget' . ($qh === '' ? ' is-bare' : '') . '">' . $qhHtml
                    . '<div class="ppd-quicklinks">' . $links . '</div></div>';

            case 'task-list': {
                $dyn        = ($p['source'] ?? 'manual') === 'dynamic';
                $showChecks = filter_var($p['showChecks'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $strikeDone = filter_var($p['strikeDone'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $scroll     = filter_var($p['scroll'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $countBadge = filter_var($p['countBadge'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $heading    = self::e((string) ($p['heading'] ?? 'Tasks'));
                $listOpen   = '<div class="ppd-task-list' . ($scroll ? ' is-scroll' : '') . '">';

                // Dynamic: rows come from a table. WHERE is expanded via SqlVars at
                // request time; ORDER BY / LIMIT are baked (sanitised) at build.
                if ($dyn && self::ident($p['table'] ?? '') !== '') {
                    $t     = self::ident($p['table']);
                    $tc    = self::ident($p['titleColumn'] ?? '');
                    $dcol  = self::ident($p['doneColumn'] ?? '');
                    $ob    = self::ident($p['orderBy'] ?? '');
                    $dir   = strtolower((string) ($p['orderDir'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
                    $lim   = max(1, min(50, (int) ($p['limit'] ?? 6)));
                    $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $empty = self::e((string) ($p['emptyText'] ?? 'No tasks'));
                    $order = $ob !== '' ? " ORDER BY `$ob` $dir" : '';

                    $sqlExpr = $where !== ''
                        ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . "' . $order . ' LIMIT ' . $lim . '"'
                        : '"SELECT * FROM `' . $t . '`' . $order . ' LIMIT ' . $lim . '"';
                    $titleE = $tc !== '' ? "htmlspecialchars((string)(\$__r['$tc'] ?? ''))" : "'Task'";
                    $doneE  = $dcol !== '' ? "!empty(\$__r['$dcol'])" : 'false';

                    $php = "<?php try { \$__tl = \\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: []; } catch (\\Throwable \$__e) { \$__tl = []; } ?>";
                    $badge = $countBadge ? '<?php echo " (".count($__tl).")"; ?>' : '';
                    $headEl = '<div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . $heading . $badge . '</strong></div>';
                    $rowsPhp = "<?php if (!\$__tl) { echo '<div class=\"ppd-task-empty\">$empty</div>'; }\n"
                        . "foreach (\$__tl as \$__r) { \$__d = $doneE; \$__struck = \$__d && " . ($strikeDone ? 'true' : 'false') . ";\n"
                        . "  echo '<label class=\"ppd-task-row' . (\$__struck ? ' is-done' : '') . '\">'"
                        . ($showChecks ? " . '<input type=\"checkbox\"' . (\$__d ? ' checked' : '') . ' disabled>'" : '')
                        . " . '<span>' . $titleE . '</span></label>'; } ?>";
                    return $php . '<div class="ppd-widget">' . $headEl . $listOpen . $rowsPhp . '</div></div>';
                }

                // Manual — [x] prefix marks an item done (checked + struck).
                $items = self::arr($p['items'] ?? ['Task one', 'Task two']);
                $badge = $countBadge ? self::e(' (' . count($items) . ')') : '';
                $headEl = '<div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . $heading . $badge . '</strong></div>';
                $rows = '';
                foreach ($items as $t) {
                    $s = (string) $t;
                    $done = false;
                    if (preg_match('/^\s*\[([ xX])\]\s*/', $s, $m)) { $done = strtolower($m[1]) === 'x'; $s = substr($s, strlen($m[0])); }
                    $struck = $done && $strikeDone;
                    $rows .= '<label class="ppd-task-row' . ($struck ? ' is-done' : '') . '">'
                        . ($showChecks ? '<input type="checkbox"' . ($done ? ' checked' : '') . ' disabled>' : '')
                        . '<span>' . self::e(trim($s)) . '</span></label>';
                }
                return '<div class="ppd-widget">' . $headEl . $listOpen . $rows . '</div></div>';
            }

            case 'recent-activity':
                return self::feedWidgetPhp($p, 'Recent activity', 'bi-clock-history');
            case 'notification-list':
                return self::feedWidgetPhp($p, 'Notifications', 'bi-bell');
            case 'mini-table':
                return self::miniTablePhp($p);
            case 'calendar-widget':
                return self::calendarPhp($p, false);
            case 'ai-assist': {
                // Claude-powered record assistant: pick a record, then Summarise /
                // Next actions / Draft. The picker options are fetched server-side
                // under Row-Level Security; the enhancer (app.js) POSTs the choice
                // to api/ai-assist.php. The API key never reaches the browser.
                $t  = self::ident($p['table'] ?? '');
                $lc = self::ident($p['labelColumn'] ?? '');
                $heading = trim((string) ($p['heading'] ?? 'AI Assistant'));
                if ($t === '' || $lc === '') {
                    return '<div class="ppd-note">' . self::ic('bi-robot') . ' Configure the AI Assistant: pick a table and a label column.</div>';
                }
                $sumL  = self::t(trim((string) ($p['summaryLabel'] ?? '')) ?: 'Summarise');
                $nextL = self::t(trim((string) ($p['nextLabel'] ?? '')) ?: 'Next actions');
                $draftL = self::t(trim((string) ($p['draftLabel'] ?? '')) ?: 'Draft notice');
                $ph = self::t(trim((string) ($p['pickerLabel'] ?? '')) ?: 'Choose a record…');
                $optsPhp = "<?php \$__aiOpts = [];\n"
                    . "try { \$__aiPk = function_exists('pp_pk') ? pp_pk('$t') : 'id';\n"
                    . "  \$__aiRls = class_exists('\\\\PpPhp\\\\Core\\\\Auth\\\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__aiOpts = \\PpPhp\\Core\\DB::FetchAll(\"SELECT `\" . \$__aiPk . \"` k, `$lc` v FROM `$t` WHERE 1=1\" . \$__aiRls . \" ORDER BY `\" . \$__aiPk . \"` DESC LIMIT 300\") ?: [];\n"
                    . "} catch (\\Throwable \$e) {} ?>";
                $selOpts = '<?php foreach ($__aiOpts as $__o): ?><option value="<?= htmlspecialchars((string) $__o[\'k\']) ?>"><?= htmlspecialchars((string) $__o[\'v\']) ?></option><?php endforeach; ?>';
                // Short setup hint shown behind a "?" (auto-hides). End users can't
                // configure AI — point them to the administrator (who enables it in
                // the console under Misc → AI).
                $info = 'AI isn\'t enabled yet. Please contact your administrator to add an AI provider key '
                    . '(in the console: <em>Misc → AI</em>) to turn on Summarise, Next actions and Draft.';
                return $optsPhp
                    . '<div class="pp-ai-assist" data-ai-table="' . self::e($t) . '">'
                    . '<div class="pp-ai-head">' . self::ic('bi-stars') . '<strong>' . self::e($heading) . '</strong>'
                    . '<button type="button" class="pp-ai-help" aria-label="How to enable AI" title="How to enable AI">?</button></div>'
                    . '<div class="pp-ai-info" hidden><i class="bi bi-info-circle"></i> ' . $info . '</div>'
                    . '<div class="pp-ai-controls"><select class="pp-ai-pick" aria-label="' . $ph . '"><option value="">' . $ph . '</option>' . $selOpts . '</select>'
                    . '<div class="pp-ai-tasks">'
                    . '<button type="button" class="pp-ai-task" data-ai-task="summary">' . $sumL . '</button>'
                    . '<button type="button" class="pp-ai-task" data-ai-task="next-action">' . $nextL . '</button>'
                    . '<button type="button" class="pp-ai-task" data-ai-task="draft">' . $draftL . '</button>'
                    . '</div></div>'
                    . '<div class="pp-ai-out" aria-live="polite"></div></div>';
            }

            case 'cause-list': {
                // Daily cause list (पेशी सूची): upcoming records within N days, grouped
                // by a column, as a clean printable table. Fully configurable — every
                // column and header comes from props (blank = hide), so it works on any
                // table. Data is fetched at request time under Row-Level Security;
                // "Print" opens a tidy window. Mirrors registry.js 'cause-list'.
                $t  = self::ident($p['table'] ?? '');
                $dc = self::ident($p['dateColumn'] ?? '');
                $gc = self::ident($p['groupColumn'] ?? '');
                if ($t === '' || $dc === '' || $gc === '') {
                    return '<div class="ppd-note">' . self::ic('bi-list-check') . ' Configure the cause list: table, date column and group column.</div>';
                }
                $days = max(0, min(90, (int) ($p['days'] ?? 7)));
                $headingSafe = self::t(trim((string) ($p['heading'] ?? 'Cause List')));
                $printSafe   = self::t(trim((string) ($p['printLabel'] ?? '')) ?: 'Print');
                $emptySafe   = self::t(trim((string) ($p['emptyLabel'] ?? '')) ?: 'No records in this window.');
                $vsExpr      = self::tExpr(trim((string) ($p['vsLabel'] ?? '')) ?: 'vs');
                // Configurable columns (all optional except the always-present serial + date).
                $numberCol = self::ident($p['numberColumn'] ?? '');
                $partyA    = self::ident($p['partyAColumn'] ?? '');
                $partyB    = self::ident($p['partyBColumn'] ?? '');
                $dateDisp  = self::ident($p['dateDisplayColumn'] ?? '');
                $typeCol   = self::ident($p['typeColumn'] ?? '');
                $repCol    = self::ident($p['repColumn'] ?? '');
                $hdr = function (string $key, string $dflt) use ($p) { return self::t(trim((string) ($p[$key] ?? '')) ?: $dflt); };
                // Build [headerHtml, rowCellPhpExpr] column defs. $__c = row, $__i = index.
                $cols = [];
                $cols[] = [$hdr('colNo', '#'), '$__i + 1'];
                if ($numberCol !== '') $cols[] = [$hdr('colCase', 'No.'), "htmlspecialchars((string) (\$__c['$numberCol'] ?? ''))"];
                if ($partyA !== '') {
                    $pExpr = "htmlspecialchars((string) (\$__c['$partyA'] ?? '')) . ' <span class=\"pp-cl-vs\">' . htmlspecialchars(" . $vsExpr . ") . '</span> ' . "
                        . ($partyB !== '' ? "htmlspecialchars((string) (\$__c['$partyB'] ?? ''))" : "''");
                    $cols[] = [$hdr('colParties', 'Parties'), $pExpr];
                }
                // Nepali-date rendering (Devanagari BS). Source era from the display column
                // suffix (_bs = already BS → just Devanagari; else AD → converted client-side).
                $clSrcEra = preg_match('/_bs$/i', ($dateDisp !== '' ? $dateDisp : $dc)) ? 'bs' : 'ad';
                $clShow   = in_array(($p['dateSystem'] ?? 'bs'), ['bs', 'ad', 'both'], true) ? (string) ($p['dateSystem'] ?? 'bs') : 'bs';
                $clBsPre  = trim((string) ($p['bsPrefix'] ?? '')) ?: 'वि.सं.';
                $clAdPre  = trim((string) ($p['adPrefix'] ?? '')) ?: 'इ.सं.';
                $clOpenSpan = '<span data-nepdate data-era="' . $clSrcEra . '" data-show="' . $clShow . '" data-pre="' . self::e($clBsPre) . '" data-adpre="' . self::e($clAdPre) . '">';
                $dateValExpr = $dateDisp !== ''
                    ? "htmlspecialchars((string) (\$__c['$dateDisp'] ?? \$__c['$dc'] ?? ''))"
                    : "htmlspecialchars((string) (\$__c['$dc'] ?? ''))";
                $dateExpr = var_export($clOpenSpan, true) . ' . ' . $dateValExpr . " . '</span>'";
                $cols[] = [$hdr('colDate', 'Date'), $dateExpr];
                // "shown date" chip = today (AD) → render as Nepali BS to match.
                $clShownSpan = '<span data-nepdate data-era="ad" data-show="' . $clShow . '" data-pre="' . self::e($clBsPre) . '" data-adpre="' . self::e($clAdPre) . '"><?= htmlspecialchars(date(\'Y-m-d\')) ?></span>';
                if ($typeCol !== '') $cols[] = [$hdr('colType', 'Type'), "htmlspecialchars((string) (\$__c['$typeCol'] ?? ''))"];
                if ($repCol !== '')  $cols[] = [$hdr('colRep', 'Rep'), "htmlspecialchars((string) (\$__c['$repCol'] ?? ''))"];
                $theadCells = ''; foreach ($cols as $col) $theadCells .= '<th>' . $col[0] . '</th>';
                // data-label carries the (translated) column header so the table can
                // collapse into stacked "label: value" cards on mobile via CSS.
                $rowCells   = ''; foreach ($cols as $col) $rowCells .= '<td data-label="' . $col[0] . '"><?= ' . $col[1] . ' ?></td>';

                $out  = "<?php\n\$__clRows = [];\ntry {\n"
                    // Window bounds in SQL (CURDATE()) — never PHP date() — so "today"
                    // matches the DB clock (PHP may be a day off in UTC).
                    . "    \$__clRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "    \$__clRows = \\PpPhp\\Core\\DB::FetchAll(\"SELECT * FROM `$t` WHERE `$dc` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL $days DAY)\" . \$__clRls . \" ORDER BY `$gc`, `$dc`\") ?: [];\n"
                    . "} catch (\\Throwable \$__e) {}\n"
                    . "\$__clGroups = [];\nforeach (\$__clRows as \$__r) { \$__clGroups[(string) (\$__r['$gc'] ?? '—')][] = \$__r; }\n?>\n"
                    . '<div class="pp-cl">'
                    . '<div class="pp-cl-bar"><button type="button" class="pp-cl-print"><i class="bi bi-printer"></i> ' . $printSafe . '</button></div>'
                    . '<div class="pp-cl-doc">'
                    . '<div class="pp-cl-h"><strong>' . $headingSafe . '</strong>' . $clShownSpan . '</div>'
                    . '<?php if ($__clRows === []): ?><div class="pp-cl-empty">' . $emptySafe . '</div><?php endif; ?>'
                    . '<?php foreach ($__clGroups as $__court => $__cases): ?>'
                    . '<div class="pp-cl-court"><?= htmlspecialchars((string) $__court) ?> <span>(<?= count($__cases) ?>)</span></div>'
                    . '<table class="pp-cl-t"><thead><tr>' . $theadCells . '</tr></thead><tbody>'
                    . '<?php foreach ($__cases as $__i => $__c): ?><tr>' . $rowCells . '</tr><?php endforeach; ?>'
                    . '</tbody></table>'
                    . '<?php endforeach; ?>'
                    . '</div></div>';
                return $out;
            }

            case 'deadline-tracker': {
                // Risk-prioritised deadline watchlist: statutes of limitation, filing
                // deadlines, hearings. Days-remaining is computed in SQL (DATEDIFF vs
                // CURDATE()) so the "today" boundary matches the DB clock, then bucketed
                // in PHP (overdue / critical / soon / upcoming). RLS-scoped.
                $t  = self::ident($p['table'] ?? '');
                $dc = self::ident($p['dateColumn'] ?? '');
                if ($t === '' || $dc === '') {
                    return '<div class="ppd-note">' . self::ic('bi-alarm') . ' Configure the deadline tracker: pick a table and a deadline date column.</div>';
                }
                $titleCol = self::ident($p['titleColumn'] ?? '') ?: (self::ident($p['keyColumn'] ?? '') ?: $dc);
                $subCol   = self::ident($p['subtitleColumn'] ?? '');
                $keyCol   = self::ident($p['keyColumn'] ?? 'id') ?: 'id';
                $linkTbl  = self::ident($p['linkTable'] ?? '');
                $where    = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                $crit    = max(0, min(3650, (int) ($p['criticalDays'] ?? 3)));
                $soon    = max($crit, min(3650, (int) ($p['soonDays'] ?? 7)));
                $horizon = max($soon, min(3650, (int) ($p['horizonDays'] ?? 30)));
                $showOverdue = filter_var($p['showOverdue'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                // HTML-context labels → translatable content (pp_ct, HTML-escaped).
                $L = function (string $k, string $d) use ($p) { return self::t(trim((string) ($p[$k] ?? '')) ?: $d); };
                // Runtime-expression labels → spliced into the emitted PHP that builds
                // the day-count badge, so they must be raw pp_ct('…') expressions.
                $E = function (string $k, string $d) use ($p) { return self::tExpr(trim((string) ($p[$k] ?? '')) ?: $d); };
                $H = $L('heading', 'Deadlines & Limitations');
                $lblOverdue = $L('overdueLabel', 'Overdue'); $lblCrit = $L('criticalLabel', 'Critical');
                $lblSoon = $L('soonLabel', 'Due soon'); $lblUp = $L('upcomingLabel', 'Upcoming');
                $exIn = $E('dueInLabel', 'in'); $exBy = $E('overdueByLabel', 'overdue');
                $exDays = $E('daysLabel', 'days'); $exDay = $E('dayLabel', 'day'); $exToday = $E('todayLabel', 'today');
                $lblEmpty = $L('emptyLabel', 'No upcoming deadlines.'); $lblOpen = $L('openLabel', 'Open');
                $selSub = $subCol !== '' ? ", `$subCol` s" : '';
                $whereExpr = $where !== '' ? '" . " AND (" . \\PpPhp\\Core\\SqlVars::expand(' . var_export($where, true) . ') . ")' : '';
                $overdueClause = $showOverdue ? '' : ' AND DATE(`' . $dc . '`) >= CURDATE()';
                // Hover/focus preview: the table whose record each row maps to. The
                // client fetches a curated, RLS-scoped snapshot from api/record-preview.php.
                $previewTbl = $linkTbl !== '' ? $linkTbl : $t;
                $dlDataAttrs = ' data-dl-preview="1" data-dl-table="' . self::e($previewTbl) . '"';

                $out  = "<?php\n\$__dlRows = [];\ntry {\n"
                    . "  \$__dlRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__dlRows = \\PpPhp\\Core\\DB::FetchAll(\"SELECT `$keyCol` k, `$titleCol` t$selSub, DATEDIFF(`$dc`, CURDATE()) dleft FROM `$t` WHERE `$dc` IS NOT NULL AND DATE(`$dc`) <= DATE_ADD(CURDATE(), INTERVAL $horizon DAY)$overdueClause$whereExpr\" . \$__dlRls . \" ORDER BY `$dc` ASC LIMIT 100\") ?: [];\n"
                    . "} catch (\\Throwable \$__e) {}\n"
                    . "\$__dlBucket = function(\$__n){ return \$__n < 0 ? 'overdue' : (\$__n <= $crit ? 'critical' : (\$__n <= $soon ? 'soon' : 'upcoming')); };\n"
                    . "\$__dlCounts = ['overdue'=>0,'critical'=>0,'soon'=>0,'upcoming'=>0];\n"
                    . "foreach (\$__dlRows as \$__r) { \$__dlCounts[\$__dlBucket((int)\$__r['dleft'])]++; }\n?>\n"
                    . '<div class="pp-dl"' . $dlDataAttrs . '>'
                    . '<div class="pp-dl-head"><span class="pp-dl-titlewrap"><i class="bi bi-alarm"></i> <strong>' . $H . '</strong></span><span class="pp-dl-chips">'
                    . '<?php if ($__dlCounts[\'overdue\']): ?><span class="pp-dl-chip r-overdue"><?= $__dlCounts[\'overdue\'] ?> ' . $lblOverdue . '</span><?php endif; ?>'
                    . '<?php if ($__dlCounts[\'critical\']): ?><span class="pp-dl-chip r-critical"><?= $__dlCounts[\'critical\'] ?> ' . $lblCrit . '</span><?php endif; ?>'
                    . '<?php if ($__dlCounts[\'soon\']): ?><span class="pp-dl-chip r-soon"><?= $__dlCounts[\'soon\'] ?> ' . $lblSoon . '</span><?php endif; ?>'
                    . '<?php if ($__dlCounts[\'upcoming\']): ?><span class="pp-dl-chip r-upcoming"><?= $__dlCounts[\'upcoming\'] ?> ' . $lblUp . '</span><?php endif; ?>'
                    . '</span></div>'
                    . '<?php if ($__dlRows === []): ?><div class="pp-dl-empty">' . $lblEmpty . '</div><?php endif; ?>'
                    . '<div class="pp-dl-list">'
                    . '<?php foreach ($__dlRows as $__r): $__n = (int) $__r[\'dleft\']; $__b = $__dlBucket($__n);'
                    . ' if ($__n < 0) { $__cd = ' . $exBy . ' . \' \' . abs($__n) . \' \' . (abs($__n) === 1 ? ' . $exDay . ' : ' . $exDays . '); }'
                    . ' elseif ($__n === 0) { $__cd = ' . $exToday . '; }'
                    . ' else { $__cd = trim(' . $exIn . ' . \' \' . $__n . \' \' . ($__n === 1 ? ' . $exDay . ' : ' . $exDays . ')); } ?>'
                    . '<div class="pp-dl-item is-preview r-<?= $__b ?>" data-k="<?= htmlspecialchars((string) ($__r[\'k\'] ?? \'\'), ENT_QUOTES) ?>" tabindex="0"><span class="pp-dl-dot"></span>'
                    . '<div class="pp-dl-main"><div class="pp-dl-t"><?= htmlspecialchars((string) ($__r[\'t\'] ?? \'\')) ?></div>'
                    . ($subCol !== '' ? '<?php if (trim((string) ($__r[\'s\'] ?? \'\')) !== \'\'): ?><div class="pp-dl-s"><?= htmlspecialchars((string) $__r[\'s\']) ?></div><?php endif; ?>' : '')
                    . '</div><span class="pp-dl-badge"><?= htmlspecialchars($__cd) ?></span>'
                    . ($linkTbl !== '' ? '<a class="pp-dl-open" href="<?= APP_BASE ?>/generated/' . $linkTbl . '/view.php?id=<?= urlencode((string) ($__r[\'k\'] ?? \'\')) ?>">' . $lblOpen . '</a>' : '')
                    . '</div>'
                    . '<?php endforeach; ?>'
                    . '</div></div>';
                return $out;
            }

            case 'doc-generator': {
                // Legal document automation: pick a record, merge its fields into a
                // {{token}} template, preview + print/download. Works WITHOUT AI (pure
                // merge). Record data is fetched RLS-scoped at REQUEST time (per-user),
                // limited to the columns the template actually uses + a bounded LIMIT,
                // so the page never carries rows the viewer can't see. Mirrors
                // registry.js 'doc-generator'.
                $t   = self::ident($p['table'] ?? '');
                if ($t === '') {
                    return '<div class="ppd-note">' . self::ic('bi-file-earmark-text') . ' Configure the document generator: pick a table.</div>';
                }
                $lc  = self::ident($p['labelColumn'] ?? '');
                $tpl = (string) ($p['template'] ?? '');
                // Columns referenced by {{token}} (minus the {{today}} special).
                // Special tokens are computed at render/merge time — they are NOT
                // columns, so they must never enter the SELECT (a stray `time` column
                // errors the whole query → an empty picker).
                $dgSpecial = ['today', 'now', 'time', 'today_bs', 'today_ad'];
                preg_match_all('/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/', $tpl, $mm);
                $cols = [];
                foreach (array_unique($mm[1] ?? []) as $tok) {
                    if (in_array(strtolower($tok), $dgSpecial, true)) continue;
                    $cc = self::ident($tok);
                    if ($cc !== '') $cols[$cc] = true;
                }
                $cols = array_keys($cols);
                $H       = self::t(trim((string) ($p['heading'] ?? 'Document Generator')));
                $pickL   = self::t(trim((string) ($p['pickerLabel'] ?? '')) ?: 'Choose a record…');
                $genL    = self::t(trim((string) ($p['generateLabel'] ?? '')) ?: 'Generate');
                $printL  = self::t(trim((string) ($p['printLabel'] ?? '')) ?: 'Print');
                $dlL     = self::t(trim((string) ($p['downloadLabel'] ?? '')) ?: 'Text');
                $pdfL    = self::t(trim((string) ($p['pdfLabel'] ?? '')) ?: 'PDF');
                $emptyL  = self::t(trim((string) ($p['emptyLabel'] ?? '')) ?: 'Pick a record and Generate to preview the document.');
                // Date era + time preferences (read by the enhancer via data-*).
                $dateSys = in_array($p['dateSystem'] ?? 'bs', ['bs', 'ad', 'both'], true) ? (string) $p['dateSystem'] : 'bs';
                $bsPre   = self::e(trim((string) ($p['bsPrefix'] ?? '')) ?: 'वि.सं.');
                $adPre   = self::e(trim((string) ($p['adPrefix'] ?? '')) ?: 'इ.सं.');
                $showTime = filter_var($p['showTime'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $defTime = self::e(preg_match('/^\d{1,2}:\d{2}$/', (string) ($p['defaultTime'] ?? '')) ? (string) $p['defaultTime'] : '10:00');
                $timeL   = self::t(trim((string) ($p['timeLabel'] ?? '')) ?: 'Time');
                $timeFmt = ($p['timeFormat'] ?? '24') === '12' ? '12' : '24';
                $timeInput = $showTime ? '<input type="time" class="pp-dg-time" value="' . $defTime . '" aria-label="' . $timeL . '" title="' . $timeL . '">' : '';
                // Letterhead (org/court name + logo) — merged with the same {{tokens}}.
                $lhText  = (string) ($p['letterhead'] ?? '');
                $lhLogo  = trim((string) ($p['letterheadLogo'] ?? ''));
                $lhAlign = in_array($p['letterheadAlign'] ?? 'center', ['center', 'left', 'right'], true) ? (string) $p['letterheadAlign'] : 'center';
                // Letterhead tokens must be fetched too (union with the body's columns).
                preg_match_all('/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/', $lhText, $lhm);
                foreach (array_unique($lhm[1] ?? []) as $tok) {
                    if (in_array(strtolower($tok), $dgSpecial, true)) continue;
                    $cc = self::ident($tok); if ($cc !== '' && !in_array($cc, $cols, true)) $cols[] = $cc;
                }
                // Send (Email/SMS): the recipient columns stay SERVER-SIDE (never baked
                // into the page). The client only knows a button exists + this doc's id;
                // api/doc-send.php resolves the recipient from the record itself.
                $emailCol = self::ident($p['emailColumn'] ?? '');
                $phoneCol = self::ident($p['phoneColumn'] ?? '');
                $emailLabel = self::t(trim((string) ($p['emailLabel'] ?? '')) ?: 'Email');
                $smsLabel   = self::t(trim((string) ($p['smsLabel'] ?? '')) ?: 'SMS');
                $docId = self::e((string) ($c['id'] ?? ''));
                $sendBtns = ($emailCol !== '' ? '<button type="button" class="pp-dg-email" hidden><i class="bi bi-envelope"></i> ' . $emailLabel . '</button>' : '')
                    . ($phoneCol !== '' ? '<button type="button" class="pp-dg-sms" hidden><i class="bi bi-chat-dots"></i> ' . $smsLabel . '</button>' : '');
                $ncAttrs = ' data-dg-id="' . $docId . '" data-dg-datesystem="' . self::e($dateSys) . '" data-dg-bsprefix="' . $bsPre . '" data-dg-adprefix="' . $adPre . '"'
                    . ' data-dg-timeformat="' . $timeFmt . '" data-dg-lhalign="' . self::e($lhAlign) . '"';
                $colsPhp = var_export($cols, true);

                $fetch = "<?php\n\$__dgRows = []; \$__dgCols = $colsPhp;\n"
                    . "try {\n"
                    . "  \$__dgPk = function_exists('pp_pk') ? pp_pk('$t') : 'id';\n"
                    . "  \$__dgLbl = " . ($lc !== '' ? var_export($lc, true) : '$__dgPk') . ";\n"
                    . "  \$__dgRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__dgSel = '';\n  foreach (\$__dgCols as \$__c) \$__dgSel .= ', `' . \$__c . '`';\n"
                    . "  \$__dgSql = \"SELECT `\" . \$__dgPk . \"` __k, `\" . \$__dgLbl . \"` __lbl\" . \$__dgSel . \" FROM `$t` WHERE 1=1\" . \$__dgRls . \" ORDER BY `\" . \$__dgPk . \"` DESC LIMIT 200\";\n"
                    . "  \$__dgRows = \\PpPhp\\Core\\DB::FetchAll(\$__dgSql) ?: [];\n"
                    . "} catch (\\Throwable \$__e) {}\n"
                    . "\$__dgMap = [];\nforeach (\$__dgRows as \$__r) { \$__k = (string) (\$__r['__k'] ?? ''); \$__one = []; foreach (\$__dgCols as \$__c) \$__one[\$__c] = (string) (\$__r[\$__c] ?? ''); \$__dgMap[\$__k] = \$__one; }\n?>";

                $out  = $fetch
                    . '<div class="pp-dg"' . $ncAttrs . '>'
                    . '<div class="pp-dg-head"><i class="bi bi-file-earmark-text"></i> <strong>' . $H . '</strong></div>'
                    . '<div class="pp-dg-bar">'
                    . '<select class="pp-dg-pick" aria-label="' . $pickL . '"><option value="">' . $pickL . '</option>'
                    . '<?php foreach ($__dgRows as $__r): ?><option value="<?= htmlspecialchars((string) ($__r[\'__k\'] ?? \'\')) ?>"><?= htmlspecialchars((string) ($__r[\'__lbl\'] ?? \'\')) ?></option><?php endforeach; ?>'
                    . '</select>'
                    . $timeInput
                    . '<button type="button" class="pp-dg-gen"><i class="bi bi-magic"></i> ' . $genL . '</button>'
                    . '<button type="button" class="pp-dg-print" hidden><i class="bi bi-printer"></i> ' . $printL . '</button>'
                    . '<button type="button" class="pp-dg-pdf" hidden><i class="bi bi-file-earmark-pdf"></i> ' . $pdfL . '</button>'
                    . '<button type="button" class="pp-dg-download" hidden><i class="bi bi-download"></i> ' . $dlL . '</button>'
                    . $sendBtns
                    . '</div>'
                    . '<div class="pp-dg-sendmsg" role="status" aria-live="polite"></div>'
                    . '<script type="application/json" class="pp-dg-data"><?= json_encode([\'tpl\' => ' . var_export($tpl, true) . ', \'lh\' => ' . var_export($lhText, true) . ', \'logo\' => ' . var_export($lhLogo, true) . ', \'rows\' => $__dgMap], JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>'
                    . '<div class="pp-dg-doc"><div class="pp-dg-empty">' . $emptyL . '</div></div>'
                    . '</div>';
                return $out;
            }

            case 'fee-calculator': {
                // Pure client-side slab calculator — no DB. Config travels as data-*.
                $H = self::t(trim((string) ($p['heading'] ?? 'Court Fee Calculator')));
                $claimL = self::t(trim((string) ($p['claimLabel'] ?? '')) ?: 'Claim amount');
                $feeL = self::t(trim((string) ($p['feeLabel'] ?? '')) ?: 'Court fee');
                $prefix = self::e((string) ($p['prefix'] ?? 'रु '));
                $slabs = self::e((string) ($p['slabs'] ?? ''));
                $minFee = (float) ($p['minFee'] ?? 0);
                $roundTo = max(0.0, (float) ($p['roundTo'] ?? 1));
                $note = trim((string) ($p['note'] ?? ''));
                return '<div class="pp-fc" data-fc-slabs="' . $slabs . '" data-fc-min="' . $minFee . '" data-fc-round="' . $roundTo . '" data-fc-prefix="' . $prefix . '">'
                    . '<div class="pp-fc-head"><i class="bi bi-calculator"></i> <strong>' . $H . '</strong></div>'
                    . '<div class="pp-fc-row"><label>' . $claimL . '</label><input type="number" class="pp-fc-claim" placeholder="0" min="0"></div>'
                    . '<div class="pp-fc-out"><span>' . $feeL . '</span><strong class="pp-fc-fee">' . $prefix . '0</strong></div>'
                    . ($note !== '' ? '<div class="pp-fc-note">' . self::t($note) . '</div>' : '') . '</div>';
            }

            case 'limitation-calculator': {
                // Pure client-side: start date + case type → limitation deadline.
                $H = self::t(trim((string) ($p['heading'] ?? 'Limitation Calculator')));
                $rules = (string) ($p['rules'] ?? '');
                $opts = '';
                foreach (explode(',', $rules) as $r) { $k = trim((string) (explode(':', $r)[0] ?? '')); if ($k !== '' && strtolower($k) !== 'default') $opts .= '<option>' . self::e($k) . '</option>'; }
                $warn = (int) ($p['warnDays'] ?? 30);
                $L = function (string $k, string $d) use ($p) { return self::t(trim((string) ($p[$k] ?? '')) ?: $d); };
                $bsPre = self::e(trim((string) ($p['bsPrefix'] ?? '')) ?: 'वि.सं.');
                return '<div class="pp-lc" data-lc-rules="' . self::e($rules) . '" data-lc-warn="' . $warn . '" data-lc-bsprefix="' . $bsPre . '" data-lc-days="' . $L('daysLabel', 'days') . '" data-lc-left="' . $L('leftLabel', 'left') . '" data-lc-overdue="' . $L('overdueLabel', 'lapsed') . '">'
                    . '<div class="pp-lc-head"><i class="bi bi-hourglass-split"></i> <strong>' . $H . '</strong></div>'
                    . '<div class="pp-lc-row"><label>' . $L('startLabel', 'Start date') . '</label><input type="text" class="pp-lc-start pp-ndp-input" data-pp-ndp=\'{"language":"nepali","miniEnglishDates":true,"unicodeDate":true,"dateFormat":"YYYY-MM-DD"}\' placeholder="वि.सं. मिति छान्नुहोस्…" readonly autocomplete="off"></div>'
                    . '<div class="pp-lc-row"><label>' . $L('typeLabel', 'Case type') . '</label><select class="pp-lc-type">' . $opts . '</select></div>'
                    . '<div class="pp-lc-out r-upcoming"><span>' . $L('deadlineLabel', 'Limitation deadline') . '</span><strong class="pp-lc-deadline">—</strong><span class="pp-lc-left"></span></div></div>';
            }

            case 'activity-timeline': {
                // Chronological vertical timeline, RLS-scoped, newest first.
                $t  = self::ident($p['table'] ?? '');
                $dc = self::ident($p['dateColumn'] ?? '');
                if ($t === '' || $dc === '') return '<div class="ppd-note">' . self::ic('bi-clock-history') . ' Configure the timeline: table + date column.</div>';
                $titleCol = self::ident($p['titleColumn'] ?? '') ?: (self::ident($p['keyColumn'] ?? '') ?: $dc);
                $subCol = self::ident($p['subtitleColumn'] ?? '');
                $keyCol = self::ident($p['keyColumn'] ?? 'id') ?: 'id';
                $linkTbl = self::ident($p['linkTable'] ?? '');
                $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                $limit = max(1, min(100, (int) ($p['limit'] ?? 12)));
                $H = self::t(trim((string) ($p['heading'] ?? 'Recent Activity')));
                $emptyL = self::t(trim((string) ($p['emptyLabel'] ?? '')) ?: 'No activity yet.');
                // Nepali-date rendering: source era from the column suffix (_bs = already BS,
                // else treated as AD and converted). dateSystem picks the display (bs default).
                $tlSrcEra = preg_match('/_bs$/i', $p['dateColumn'] ?? '') ? 'bs' : 'ad';
                $tlShow = in_array(($p['dateSystem'] ?? 'bs'), ['bs', 'ad', 'both'], true) ? (string) ($p['dateSystem'] ?? 'bs') : 'bs';
                $tlBsPre = self::e(trim((string) ($p['bsPrefix'] ?? '')) ?: 'वि.सं.');
                $tlAdPre = self::e(trim((string) ($p['adPrefix'] ?? '')) ?: 'इ.सं.');
                $sel = "`$keyCol` k, `$dc` d, `$titleCol` t" . ($subCol !== '' ? ", `$subCol` s" : '');
                $whereExpr = $where !== '' ? '" . " AND (" . \\PpPhp\\Core\\SqlVars::expand(' . var_export($where, true) . ') . ")' : '';
                $out  = "<?php\n\$__tlRows = []; try {\n"
                    . "  \$__tlRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__tlRows = \\PpPhp\\Core\\DB::FetchAll(\"SELECT $sel FROM `$t` WHERE `$dc` IS NOT NULL$whereExpr\" . \$__tlRls . \" ORDER BY `$dc` DESC LIMIT $limit\") ?: [];\n"
                    . "} catch (\\Throwable \$__e) {}\n?>"
                    . '<div class="pp-tl"><div class="pp-tl-head"><i class="bi bi-clock-history"></i> <strong>' . $H . '</strong></div>'
                    . '<?php if ($__tlRows === []): ?><div class="pp-tl-empty">' . $emptyL . '</div><?php else: ?><div class="pp-tl-list">'
                    . '<?php foreach ($__tlRows as $__r): ?><div class="pp-tl-item"><span class="pp-tl-dot"></span><div class="pp-tl-body">'
                    . '<div class="pp-tl-date"><span data-nepdate data-era="' . $tlSrcEra . '" data-show="' . $tlShow . '" data-pre="' . $tlBsPre . '" data-adpre="' . $tlAdPre . '"><?= htmlspecialchars((string) ($__r[\'d\'] ?? \'\')) ?></span></div>'
                    . '<div class="pp-tl-title">' . ($linkTbl !== '' ? '<a href="<?= APP_BASE ?>/generated/' . $linkTbl . '/view.php?id=<?= urlencode((string) ($__r[\'k\'] ?? \'\')) ?>"><?= htmlspecialchars((string) ($__r[\'t\'] ?? \'\')) ?></a>' : '<?= htmlspecialchars((string) ($__r[\'t\'] ?? \'\')) ?>') . '</div>'
                    . ($subCol !== '' ? '<?php if (trim((string) ($__r[\'s\'] ?? \'\')) !== \'\'): ?><div class="pp-tl-sub"><?= htmlspecialchars((string) $__r[\'s\']) ?></div><?php endif; ?>' : '')
                    . '</div></div><?php endforeach; ?></div><?php endif; ?></div>';
                return $out;
            }

            case 'daily-cause-list': {
                // Pick a date → that day's hearings grouped by court, printable. A ±window
                // of rows is baked RLS-scoped as JSON; the client filters by the chosen date.
                $t  = self::ident($p['table'] ?? '');
                $dc = self::ident($p['dateColumn'] ?? '');
                $gc = self::ident($p['groupColumn'] ?? '');
                if ($t === '' || $dc === '' || $gc === '') return '<div class="ppd-note">' . self::ic('bi-calendar2-week') . ' Configure the daily cause list: table, date column and group column.</div>';
                $win = max(1, min(180, (int) ($p['window'] ?? 45)));
                $numberCol = self::ident($p['numberColumn'] ?? '');
                $partyA = self::ident($p['partyAColumn'] ?? '');
                $partyB = self::ident($p['partyBColumn'] ?? '');
                $typeCol = self::ident($p['typeColumn'] ?? '');
                $repCol = self::ident($p['repColumn'] ?? '');
                $H = self::t(trim((string) ($p['heading'] ?? 'Daily Cause List')));
                $printSafe = self::t(trim((string) ($p['printLabel'] ?? '')) ?: 'Print');
                // columns to bake per row
                $sel = "DATE(`$dc`) d, `$gc` g";
                $keys = ['g'];
                foreach (['num' => $numberCol, 'a' => $partyA, 'b' => $partyB, 'ty' => $typeCol, 'rep' => $repCol] as $al => $col) { if ($col !== '') { $sel .= ", `$col` $al"; $keys[] = $al; } }
                // header + column config for the client. The cfg is json_encode()d at
                // REQUEST time from pp_ct() expressions, so every header/label follows
                // the active language (English when English is selected, …).
                $colParts = [];
                $colParts[] = '[' . self::tExpr('#') . ", 'serial']";
                if ($numberCol !== '') $colParts[] = '[' . self::tExpr(trim((string) ($p['colCase'] ?? '')) ?: 'No.') . ", 'num']";
                if ($partyA !== '') $colParts[] = '[' . self::tExpr(trim((string) ($p['colParties'] ?? '')) ?: 'Parties') . ", 'parties']";
                $colParts[] = '[' . self::tExpr(trim((string) ($p['colType'] ?? '')) ?: 'Type') . ', ' . var_export($typeCol !== '' ? 'ty' : '-', true) . ']';
                if ($repCol !== '') $colParts[] = '[' . self::tExpr(trim((string) ($p['colRep'] ?? '')) ?: 'Rep') . ", 'rep']";
                $cfgExpr = "['cols' => [" . implode(', ', $colParts) . "], 'vs' => " . self::tExpr(trim((string) ($p['vsLabel'] ?? '')) ?: 'vs')
                    . ", 'colNo' => " . self::tExpr(trim((string) ($p['colNo'] ?? '')) ?: '#')
                    . ", 'empty' => " . self::tExpr(trim((string) ($p['emptyLabel'] ?? '')) ?: 'No hearings on this date.') . ']';
                $out  = "<?php\n\$__dclRows = []; try {\n"
                    . "  \$__dclRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__dclRows = \\PpPhp\\Core\\DB::FetchAll(\"SELECT $sel FROM `$t` WHERE DATE(`$dc`) BETWEEN DATE_SUB(CURDATE(), INTERVAL $win DAY) AND DATE_ADD(CURDATE(), INTERVAL $win DAY)\" . \$__dclRls . \" ORDER BY `$gc`, `$dc`\") ?: [];\n"
                    . "} catch (\\Throwable \$__e) {}\n?>"
                    . '<div class="pp-cl pp-dcl">'
                    . '<div class="pp-cl-bar"><input type="date" class="pp-dcl-date" value="<?= htmlspecialchars(date(\'Y-m-d\')) ?>"><button type="button" class="pp-cl-print"><i class="bi bi-printer"></i> ' . $printSafe . '</button></div>'
                    . '<script type="application/json" class="pp-dcl-data"><?= json_encode([\'cfg\' => ' . $cfgExpr . ', \'rows\' => $__dclRows], JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>'
                    . '<div class="pp-cl-doc"><div class="pp-cl-h"><strong>' . $H . '</strong><span class="pp-dcl-shown"><?= htmlspecialchars(date(\'Y-m-d\')) ?></span></div>'
                    . '<div class="pp-dcl-body"></div></div></div>';
                return $out;
            }

            case 'portal-link': {
                // Staff picks a record → a signed, login-free public status link. Tokens
                // are HMAC(table|id, secret) computed server-side; the public page
                // (portal.php) verifies them + shows only whitelisted columns.
                $t  = self::ident($p['table'] ?? '');
                if ($t === '') return '<div class="ppd-note">' . self::ic('bi-share') . ' Configure the portal link: pick a table.</div>';
                $lc = self::ident($p['labelColumn'] ?? '');
                $H = self::t(trim((string) ($p['heading'] ?? 'Client Status Link')));
                $pickL = self::t(trim((string) ($p['pickerLabel'] ?? '')) ?: 'Choose a record…');
                $copyL = self::t(trim((string) ($p['copyLabel'] ?? '')) ?: 'Copy link');
                $hint = trim((string) ($p['hint'] ?? ''));
                $optsPhp = "<?php \$__plOpts = []; try {\n"
                    . "  \$__plPk = function_exists('pp_pk') ? pp_pk('$t') : 'id'; if (!\$__plPk) \$__plPk = 'id';\n"
                    . "  \$__plLbl = " . ($lc !== '' ? var_export($lc, true) : '$__plPk') . ";\n"
                    . "  \$__plRls = class_exists('\\PpPhp\\Core\\Auth\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                    . "  \$__plSecret = function_exists('pp_portal_secret') ? pp_portal_secret() : 'x';\n"
                    . "  \$__plOpts = \\PpPhp\\Core\\DB::FetchAll(\"SELECT `\" . \$__plPk . \"` k, `\" . \$__plLbl . \"` v FROM `$t` WHERE 1=1\" . \$__plRls . \" ORDER BY `\" . \$__plPk . \"` DESC LIMIT 300\") ?: [];\n"
                    . "} catch (\\Throwable \$__e) {} ?>";
                $selOpts = '<?php foreach ($__plOpts as $__o): $__pd = \'' . $t . ':\' . $__o[\'k\']; $__pt = hash_hmac(\'sha256\', \'' . $t . '|\' . $__o[\'k\'], $__plSecret); ?>'
                    . '<option value="<?= htmlspecialchars(APP_BASE . \'/portal.php?d=\' . rawurlencode($__pd) . \'&t=\' . $__pt) ?>"><?= htmlspecialchars((string) ($__o[\'v\'] ?? \'\')) ?></option><?php endforeach; ?>';
                return $optsPhp
                    . '<div class="pp-pl"><div class="pp-pl-head"><i class="bi bi-share"></i> <strong>' . $H . '</strong></div>'
                    . '<div class="pp-pl-bar"><select class="pp-pl-pick"><option value="">' . $pickL . '</option>' . $selOpts . '</select>'
                    . '<button type="button" class="pp-pl-copy"><i class="bi bi-clipboard"></i> ' . $copyL . '</button>'
                    . '<a class="pp-pl-open" href="#" target="_blank" rel="noopener" hidden><i class="bi bi-box-arrow-up-right"></i></a></div>'
                    . '<div class="pp-pl-out"><input type="text" class="pp-pl-url" readonly placeholder="…/portal.php?d=…&t=…"></div>'
                    . ($hint !== '' ? '<div class="pp-pl-hint">' . self::t($hint) . '</div>' : '') . '</div>';
            }

            case 'calendar':
                return self::calendarPhp($p, true, (string) ($c['id'] ?? ''));
            case 'kanban':
                return self::kanbanPhp($p);

            case 'section': {
                // Generic container: header + a 12-col body of children. Foldable
                // collapses the body via a tiny inline toggle (no framework).
                $variant  = ($p['variant'] ?? 'card') === 'plain' ? 'plain' : 'card';
                $foldable = filter_var($p['foldable'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $closed   = $foldable && filter_var($p['closedInitially'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $hh = (string) ($p['headerHeight'] ?? 'md');
                if (!in_array($hh, ['sm', 'md', 'lg'], true)) $hh = 'md';
                $hs = '';
                $hb = self::cssColor($p['headerBg'] ?? '');    if ($hb !== '') $hs .= 'background:' . $hb . ';';
                $hc = self::cssColor($p['headerColor'] ?? ''); if ($hc !== '') $hs .= 'color:' . $hc . ';';
                $hf = self::css((string) ($p['headerFont'] ?? '')); if ($hf !== '') $hs .= 'font-family:' . $hf . ';';
                $sv = '';
                $bb = self::cssColor($p['bodyBg'] ?? ''); if ($bb !== '') $sv .= '--sec-body-bg:' . $bb . ';';
                $bp = self::cssLen((string) ($p['bodyPadding'] ?? '')); if ($bp !== '') $sv .= '--sec-body-pad:' . $bp . ';';
                $bc = self::cssColor($p['borderColor'] ?? ''); if ($bc !== '') $sv .= '--sec-border:' . $bc . ';';
                $rd = self::cssLen((string) ($p['radius'] ?? '')); if ($rd !== '') $sv .= '--sec-radius:' . $rd . ';';
                $shadow = in_array($p['shadow'] ?? 'none', ['none', 'sm', 'md', 'lg'], true) ? (string) ($p['shadow'] ?? 'none') : 'none';
                $shadowCls = ($variant !== 'plain' && $shadow !== 'none') ? ' shadow-' . $shadow : '';
                $chev = $foldable ? '<i class="bi bi-chevron-' . ($closed ? 'right' : 'down') . ' ppd-fold-ic"></i>' : '';
                $hClick = $foldable ? ' onclick="this.parentElement.classList.toggle(\'is-collapsed\')" style="cursor:pointer;' . ($hs !== '' ? self::e($hs) : '') . '"' : ($hs !== '' ? ' style="' . self::e($hs) . '"' : '');
                return '<div class="ppd-c-section ' . $variant . ($foldable ? ' is-foldable' : '') . ($closed ? ' is-collapsed' : '') . $shadowCls . '"' . ($sv !== '' ? ' style="' . self::e($sv) . '"' : '') . '>'
                    . '<div class="h hh-' . $hh . '"' . $hClick . '>' . self::ic((string) ($p['icon'] ?? 'bi-collection'))
                    . '<span class="ppd-sec-title">' . self::chromeHeading((string) ($p['heading'] ?? $label ?: 'Section')) . '</span>' . $chev . '</div>'
                    . '<div class="b"><div class="pp-zone12 gap-normal">' . $renderChildren($c) . '</div></div></div>';
            }

            case 'summary': {
                // Metric-item bar. Each item is a manual value OR a live DB aggregate
                // (count/sum/avg/min/max + SqlVars WHERE) with number formatting,
                // per-item colour, and an optional click-through link.
                $cols = in_array($p['columns'] ?? 'auto', ['auto', '2', '3', '4'], true) ? (string) ($p['columns'] ?? 'auto') : 'auto';
                $align = ($p['align'] ?? '') === 'center' ? ' is-center' : '';
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $shadow = in_array($p['shadow'] ?? 'none', ['none', 'sm', 'md', 'lg'], true) ? (string) ($p['shadow'] ?? 'none') : 'none';
                $itemBg = self::cssColor($p['itemBg'] ?? '');
                $gap = self::cssLen((string) ($p['gap'] ?? ''));
                $heading = trim((string) ($p['heading'] ?? ''));
                // Per-card responsive width: each item may carry spanD/spanT/spanM
                // (1–12 columns). When any does, the strip becomes a 12-col grid
                // (.is-spanned) and each card spans its own width per device.
                $kpiSpan = static fn ($v): int => ((int) $v >= 1 && (int) $v <= 12) ? (int) $v : 0;
                $kpiVars = static function (array $it) use ($kpiSpan): string {
                    $s = '';
                    if ($d = $kpiSpan($it['spanD'] ?? 0)) $s .= '--ksp:' . $d . ';';
                    if ($t = $kpiSpan($it['spanT'] ?? 0)) $s .= '--kspt:' . $t . ';';
                    if ($m = $kpiSpan($it['spanM'] ?? 0)) $s .= '--kspm:' . $m . ';';
                    return $s;
                };
                $spanned = false;
                foreach (self::arr($p['items'] ?? []) as $__it) { if ($kpiVars((array) $__it) !== '') { $spanned = true; break; } }
                $cls = 'ppd-summary' . ($spanned ? ' is-spanned' : '') . ($cols !== 'auto' ? ' cols-' . $cols : '') . $align . (in_array($size, ['sm', 'lg'], true) ? ' size-' . $size : '');
                $itemShadow = $shadow !== 'none' ? ' shadow-' . $shadow : '';
                $out = ($heading !== '' ? '<div class="ppd-summary-heading">' . self::t($heading) . '</div>' : '')
                    . '<div class="' . $cls . '"' . ($gap !== '' ? ' style="gap:' . $gap . '"' : '') . '>';
                foreach (self::arr($p['items'] ?? []) as $it) {
                    $it = (array) $it;
                    $tone = in_array($it['tone'] ?? '', ['ok', 'warn', 'danger', 'info'], true) ? (string) $it['tone'] : '';
                    $prefix = self::e((string) ($it['prefix'] ?? ''));
                    $suffix = self::e((string) ($it['suffix'] ?? ''));
                    $t = self::ident($it['table'] ?? '');
                    if ($t !== '') {
                        $agg = strtolower((string) ($it['agg'] ?? 'count'));
                        if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
                        $col = self::ident($it['column'] ?? '');
                        $aggSql = ($agg === 'count' || $col === '') ? 'COUNT(*)' : strtoupper($agg) . '(`' . $col . '`)';
                        $where = trim(str_replace(["\0", ';'], '', (string) ($it['where'] ?? '')));
                        $sqlExpr = $where !== ''
                            ? '"SELECT ' . $aggSql . ' FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ')'
                            : '"SELECT ' . $aggSql . ' FROM `' . $t . '`"';
                        $dec = max(0, min(4, (int) ($it['decimals'] ?? 0)));
                        $abbr = filter_var($it['abbreviate'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
                        $value = self::ppKpiFmtHelper()
                            . '<?php try{$__su=(float)(\PpPhp\Core\DB::FetchOne(' . $sqlExpr . ') ?: 0);}catch(\Throwable $__e){$__su=0;} ?>'
                            . $prefix . '<?php echo pp_kpi_fmt($__su,' . $dec . ',' . $abbr . ',true); ?>' . $suffix;
                    } else {
                        $value = $prefix . self::e((string) ($it['value'] ?? '0')) . $suffix;
                    }
                    $vStyle = ($vc = self::cssColor($it['valueColor'] ?? '')) !== '' ? ' style="color:' . $vc . '"' : '';
                    $__st = ($itemBg !== '' ? 'background:' . $itemBg . ';' : '') . $kpiVars($it);
                    $itemHtml = '<div class="ppd-summary-item tone-' . $tone . $itemShadow . '"' . ($__st !== '' ? ' style="' . $__st . '"' : '') . '>'
                        . self::ic((string) ($it['icon'] ?? 'bi-dot')) . '<div><strong' . $vStyle . '>' . $value . '</strong><small>' . self::t((string) ($it['label'] ?? '')) . '</small></div></div>';
                    $link = self::ident($it['linkTable'] ?? '');
                    if ($link !== '') $itemHtml = '<a class="ppd-summary-link" href="<?= APP_BASE ?>/generated/' . $link . '/list.php">' . $itemHtml . '</a>';
                    $out .= $itemHtml;
                }
                return $out . '</div>';
            }

            case 'totals':
                $items = self::arr($p['items'] ?? []);
                $t = '';
                foreach ($items as $it) {
                    $it = (array) $it;
                    $t .= '<div class="ppd-total"><small>' . self::e((string) ($it['label'] ?? '')) . '</small><strong>' . self::e((string) ($it['value'] ?? '')) . '</strong></div>';
                }
                return '<div class="ppd-totals">' . $t . '</div>';

            case 'wizard-step': {
                $steps = [];
                foreach (self::arr($p['steps'] ?? []) as $s) $steps[] = is_array($s) ? $s : ['label' => (string) $s];
                if (!$steps) $steps = [['label' => 'Step 1'], ['label' => 'Step 2'], ['label' => 'Step 3']];
                $active    = (int) ($p['active'] ?? 0);
                $marker    = (string) ($p['marker'] ?? 'number');
                $vert      = ($p['orientation'] ?? 'horizontal') === 'vertical';
                $showDesc  = filter_var($p['showDesc'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $doneCheck = filter_var($p['doneCheck'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $connector = filter_var($p['connector'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $size      = (string) ($p['size'] ?? 'md');
                $cls = 'ppd-steps'
                    . ($vert ? ' wz-vert' : (($p['labelPos'] ?? 'inline') === 'stacked' ? ' wz-stack' : ''))
                    . ($size === 'sm' ? ' wz-sm' : ($size === 'lg' ? ' wz-lg' : ''))
                    . ($marker === 'dot' ? ' wz-mk-dot' : '')
                    . ($connector ? '' : ' wz-noline');
                $g = [];
                if (($v = self::cssColor($p['accent'] ?? '')) !== '') $g[] = '--wz-accent:' . $v;
                $bg1 = self::cssColor($p['stepBg'] ?? ''); $bg2 = self::cssColor($p['stepBg2'] ?? '');
                if (filter_var($p['gradient'] ?? false, FILTER_VALIDATE_BOOLEAN) && ($bg1 !== '' || $bg2 !== '')) {
                    $angle = preg_replace('/[^0-9a-zA-Z%.\- ]/', '', (string) ($p['gradAngle'] ?? '')); if ($angle === '') $angle = '135deg';
                    $g[] = '--wz-step-bg:linear-gradient(' . $angle . ',' . ($bg1 !== '' ? $bg1 : '#6366f1') . ',' . ($bg2 !== '' ? $bg2 : ($bg1 !== '' ? $bg1 : '#8b5cf6')) . ')';
                } elseif ($bg1 !== '') {
                    $g[] = '--wz-step-bg:' . $bg1;
                }
                if (($v = self::cssColor($p['markerBg'] ?? '')) !== '') $g[] = '--wz-marker-bg:' . $v;
                if (($v = self::cssColor($p['markerColor'] ?? '')) !== '') $g[] = '--wz-marker-color:' . $v;
                if (($v = self::css((string) ($p['labelFont'] ?? ''))) !== '') $g[] = '--wz-label-font:' . $v;
                if (($v = self::cssLen($p['labelSize'] ?? '')) !== '') $g[] = '--wz-label-size:' . $v;
                if (in_array((string) ($p['labelWeight'] ?? ''), ['normal', '500', '600', '700', '800'], true)) $g[] = '--wz-label-weight:' . $p['labelWeight'];
                if (($v = self::cssColor($p['labelColor'] ?? '')) !== '') $g[] = '--wz-label-color:' . $v;
                if (($v = self::css((string) ($p['descFont'] ?? ''))) !== '') $g[] = '--wz-desc-font:' . $v;
                if (($v = self::cssLen($p['descSize'] ?? '')) !== '') $g[] = '--wz-desc-size:' . $v;
                if (($v = self::cssColor($p['descColor'] ?? '')) !== '') $g[] = '--wz-desc-color:' . $v;
                $sv = $g ? ' style="' . self::e(implode(';', $g)) . '"' : '';
                $count = count($steps);
                $out = '';
                foreach ($steps as $i => $s) {
                    $s = (array) $s;
                    $stCls = 'ppd-step' . ($i === $active ? ' is-active' : ($i < $active ? ' is-done' : ''));
                    if ($marker === 'dot') $mk = '';
                    elseif ($i < $active && $doneCheck) $mk = self::ic('bi-check-lg');
                    elseif ($marker === 'icon' && trim((string) ($s['icon'] ?? '')) !== '') $mk = self::ic((string) $s['icon']);
                    else $mk = (string) ($i + 1);
                    $lbl = '<span class="ppd-step-lbl"><span>' . self::e((string) ($s['label'] ?? '')) . '</span>'
                        . ($showDesc && trim((string) ($s['desc'] ?? '')) !== '' ? '<small>' . self::e((string) $s['desc']) . '</small>' : '') . '</span>';
                    $out .= '<div class="' . $stCls . '"><span class="ppd-step-n">' . $mk . '</span>' . $lbl . '</div>';
                    if ($i < $count - 1) $out .= '<span class="ppd-step-bar"></span>';
                }
                $stepsEl = '<div class="' . $cls . '"' . $sv . '>' . $out . '</div>';
                if (filter_var($p['counter'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
                    $cur = max(0, min($active, $count - 1));
                    return '<div class="ppd-steps-wrap"><div class="ppd-steps-counter">Step ' . ($cur + 1) . ' of ' . $count . '</div>' . $stepsEl . '</div>';
                }
                return $stepsEl;
            }

            case 'external-link': {
                $cls = self::linkClass($p['style'] ?? 'chip');
                $icName = array_key_exists('icon', $p) ? (string) $p['icon'] : 'bi-box-arrow-up-right';
                $ic = $icName !== '' ? self::ic($icName) : '';
                $href = self::e((string) ($p['href'] ?? '#'));
                $newTab = filter_var($p['newTab'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $rel = [];
                if ($newTab) $rel[] = 'noopener';
                if (filter_var($p['nofollow'] ?? false, FILTER_VALIDATE_BOOLEAN)) $rel[] = 'nofollow';
                $tip = trim((string) ($p['tooltip'] ?? ''));
                return '<a class="' . $cls . '" href="' . $href . '"' . ($newTab ? ' target="_blank"' : '')
                    . ($rel ? ' rel="' . implode(' ', $rel) . '"' : '') . ($tip !== '' ? ' title="' . self::e($tip) . '"' : '') . '>'
                    . $ic . '<span' . self::partAttr($p, 'label') . '>' . self::e((string) ($p['label'] ?? 'Open link')) . '</span>'
                    . ($newTab ? self::ic('bi-box-arrow-up-right') : '') . '</a>';
            }

            case 'download-link': {
                $cls = self::linkClass($p['style'] ?? 'chip');
                $icName = array_key_exists('icon', $p) ? (string) $p['icon'] : 'bi-download';
                $ic = $icName !== '' ? self::ic($icName) : '';
                if (($p['source'] ?? 'url') === 'table export' && self::ident($p['table'] ?? '') !== '') {
                    $t = self::ident($p['table']);
                    $fmt = in_array($p['format'] ?? 'csv', ['csv', 'xlsx', 'json', 'xml'], true) ? (string) ($p['format'] ?? 'csv') : 'csv';
                    $href = '<?= APP_BASE ?>/generated/' . $t . '/export.php?format=' . $fmt;
                    $dl = ' download';
                } else {
                    $href = self::e((string) ($p['href'] ?? '#'));
                    $name = trim((string) ($p['downloadName'] ?? ''));
                    $dl = $name !== '' ? ' download="' . self::e($name) . '"' : ' download';
                }
                return '<a class="' . $cls . '" href="' . $href . '"' . $dl . '>'
                    . $ic . '<span' . self::partAttr($p, 'label') . '>' . self::e((string) ($p['label'] ?? 'Download')) . '</span></a>';
            }

            case 'button-group': {
                $t = self::ident($p['table'] ?? '');
                $orient = ($p['orientation'] ?? 'horizontal') === 'vertical' ? 'vert' : 'horz';
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $attached = filter_var($p['attached'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $cls = 'ppd-c-buttons ppd-btngroup bg-' . $orient . ' bg-' . $size . ($attached ? ' is-attached' : '');
                $base = $t !== '' ? '<?= APP_BASE ?>/generated/' . $t : '';
                $actMap = ['add' => '/add.php', 'list' => '/list.php', 'print' => '/print.php', 'export' => '/export.php?format=csv'];
                $out = '';
                foreach (self::arr($p['buttons'] ?? []) as $b) {
                    $b = (array) $b;
                    $label = trim((string) ($b['label'] ?? ''));
                    if ($label === '') continue;
                    $v = in_array($b['variant'] ?? 'default', ['primary', 'danger'], true) ? ' ' . $b['variant'] : '';
                    $ic = trim((string) ($b['icon'] ?? '')) !== '' ? self::ic((string) $b['icon']) : '';
                    $action = (string) ($b['action'] ?? 'link');
                    if ($action === 'link' || $base === '') $href = self::e((string) ($b['href'] ?? '#'));
                    else $href = $base . ($actMap[$action] ?? '/list.php');
                    $tgt = filter_var($b['newTab'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' target="_blank" rel="noopener"' : '';
                    $out .= '<a class="ppd-bg-btn' . $v . '" href="' . $href . '"' . $tgt . '>' . $ic . '<span>' . self::e($label) . '</span></a>';
                }
                return '<div class="' . $cls . '">' . $out . '</div>';
            }

            case 'workflow-steps': {
                $steps = self::arr($p['steps'] ?? []);
                // normalise legacy CSV-of-strings into {label} rows
                $steps = array_map(static fn ($s) => is_array($s) ? $s : ['label' => (string) $s], $steps);
                $steps = array_values(array_filter($steps, static fn ($s) => trim((string) ($s['label'] ?? '')) !== ''));
                $vert = ($p['orientation'] ?? 'horizontal') === 'vertical';
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $cls = 'ppd-wf ppd-wf-' . ($vert ? 'vert' : 'horz') . ' ppd-wf-' . $size;
                $dyn = ($p['activeSource'] ?? 'manual') === 'dynamic';

                $prelude = '';
                $actExpr = (string) (int) ($p['active'] ?? 0);
                if ($dyn && trim((string) ($p['query'] ?? '')) !== '') {
                    $q = trim(str_replace(["\0", ';'], '', (string) $p['query']));
                    $prelude = '<?php try{$__wfa=(int)(\PpPhp\Core\DB::FetchOne(\PpPhp\Core\SqlVars::expand(' . var_export($q, true) . ')) ?: 0);}catch(\Throwable $__e){$__wfa=0;} ?>';
                    $actExpr = '$__wfa';
                }
                $n = count($steps);
                $out = $prelude . '<div class="' . $cls . '">';
                foreach ($steps as $i => $s) {
                    $s = (array) $s;
                    $label = self::e((string) ($s['label'] ?? ''));
                    $desc = trim((string) ($s['description'] ?? '')) !== '' ? '<small class="ppd-wf-desc">' . self::e((string) $s['description']) . '</small>' : '';
                    $iconMark = trim((string) ($s['icon'] ?? '')) !== '' ? self::ic((string) $s['icon']) : (string) ($i + 1);
                    // state decided at runtime against the (possibly dynamic) active index
                    $stateCls = '<?php echo (' . $i . ' < ' . $actExpr . ') ? "done" : ((' . $i . ' === (int)(' . $actExpr . ')) ? "active" : ""); ?>';
                    $mark = '<?php if (' . $i . ' < ' . $actExpr . '): ?><i class="bi bi-check-lg"></i><?php else: ?>' . $iconMark . '<?php endif; ?>';
                    $out .= '<div class="ppd-wf-step ' . $stateCls . '"><span class="ppd-wf-no">' . $mark . '</span>'
                        . '<span class="ppd-wf-txt"><span class="ppd-wf-lbl">' . $label . '</span>' . $desc . '</span></div>';
                    if ($i < $n - 1) $out .= '<div class="ppd-wf-bar"></div>';
                }
                return $out . '</div>';
            }

            case 'signature': {
                $mode  = ($p['mode'] ?? 'draw') === 'type' ? 'type' : 'draw';
                $field = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['fieldName'] ?? '')); if ($field === '') $field = 'signature';
                $required = filter_var($p['required'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $vars = [];
                if (($v = self::cssLen($p['height'] ?? '')) !== '') $vars[] = '--sig-h:' . $v;
                if (($v = self::cssColor($p['bgColor'] ?? '')) !== '') $vars[] = '--sig-bg:' . $v;
                if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $vars[] = '--sig-border:' . $v;
                if (($v = self::cssLen($p['radius'] ?? '')) !== '') $vars[] = '--sig-radius:' . $v;
                $pen = self::cssColor($p['penColor'] ?? ''); if ($pen !== '') $vars[] = '--sig-pen:' . $pen;
                if (($v = self::css((string) ($p['typedFont'] ?? ''))) !== '') $vars[] = '--sig-font:' . $v;
                $sv  = $vars ? ' style="' . self::e(implode(';', $vars)) . '"' : '';
                $bs  = (string) ($p['borderStyle'] ?? 'dashed');
                $cls = 'ppd-sig' . ($bs === 'solid' ? ' sig-solid' : ($bs === 'none' ? ' sig-none' : ''));
                $showLabel = filter_var($p['showLabel'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $lbl = $showLabel ? '<label>' . self::e((string) ($p['label'] ?? 'Signature')) . ($required ? ' <span class="ppd-sig-req">*</span>' : '') . '</label>' : '';

                if ($mode === 'type') {
                    $ph = self::e((string) ($p['placeholder'] ?? 'Type your name'));
                    $inner = '<input type="text" name="' . $field . '" class="ppd-sig-typebox" placeholder="' . $ph . '"' . ($required ? ' required' : '') . '>';
                    return '<div class="ppd-c-field ppd-sig-field">' . $lbl . '<div class="' . $cls . '"' . $sv . '>' . $inner . '</div></div>';
                }

                $penColor = $pen !== '' ? $pen : '#111827';
                $penWidth = (float) ($p['penWidth'] ?? 2.5); if ($penWidth <= 0) $penWidth = 2.5;
                $showUndo     = filter_var($p['showUndo'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showRedo     = filter_var($p['showRedo'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showClear    = filter_var($p['showClear'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showSave     = filter_var($p['showSave'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $showBaseline = filter_var($p['showBaseline'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $saveTable = $showSave ? self::ident($p['saveTable'] ?? '') : '';
                $saveKey   = $showSave ? self::ident($p['saveKey'] ?? '') : ''; if ($saveKey === '') $saveKey = 'id';
                // The signature COLUMN is the field name (the hidden input's name); the
                // Save button persists it to <table> WHERE <key> = <record id>.
                $saveAttrs = ($saveTable !== '') ? ' data-save-table="' . self::e($saveTable) . '" data-save-key="' . self::e($saveKey) . '"' : '';
                $inner = '<canvas class="ppd-sig-canvas"></canvas>'
                    . ($showBaseline ? '<div class="ppd-sig-baseline"></div>' : '')
                    . '<input type="hidden" name="' . $field . '" class="ppd-sig-input"' . ($required ? ' data-required="1"' : '') . '>';
                $pad = '<div class="' . $cls . '"' . $sv . ' data-pen="' . self::e($penColor) . '" data-width="' . $penWidth . '"' . $saveAttrs . '>' . $inner . '</div>';
                $btns = '';
                if ($showUndo)  $btns .= '<button type="button" class="ppd-sig-btn" data-sig="undo" title="Undo" aria-label="Undo" disabled>' . self::ic('bi-arrow-counterclockwise') . '</button>';
                if ($showRedo)  $btns .= '<button type="button" class="ppd-sig-btn" data-sig="redo" title="Redo" aria-label="Redo" disabled>' . self::ic('bi-arrow-clockwise') . '</button>';
                if ($showClear) $btns .= '<button type="button" class="ppd-sig-btn" data-sig="clear" title="Clear" aria-label="Clear">' . self::ic('bi-x-lg') . '</button>';
                if ($showSave)  $btns .= '<button type="button" class="ppd-sig-btn is-primary" data-sig="save" title="Save">' . self::ic('bi-check-lg') . ' Save</button>';
                $toolbar = $btns !== '' ? '<div class="ppd-sig-toolbar">' . $btns . '<span class="ppd-sig-msg" role="status" aria-live="polite"></span></div>' : '';
                return '<div class="ppd-c-field ppd-sig-field">' . $lbl . $pad . $toolbar . '</div>' . self::signatureScript();
            }

            case 'qr-code': {
                $v = trim((string) ($p['value'] ?? ''));
                if ($v === '') return '<div class="ppd-qr">' . self::ic('bi-qr-code') . '</div>';
                switch ((string) ($p['contentType'] ?? 'text')) {
                    case 'url':   if (!preg_match('~^[a-z][a-z0-9+.\-]*:~i', $v)) $v = 'https://' . $v; break;
                    case 'email': $v = 'mailto:' . $v; break;
                    case 'phone': $v = 'tel:' . $v; break;
                    case 'sms':   $v = 'sms:' . $v; break;
                }
                $s   = max(60, min(1000, (int) ($p['size'] ?? 160)));
                $fg  = self::qrHex($p['fgColor'] ?? '', '000000');
                $bg  = self::qrHex($p['bgColor'] ?? '', 'ffffff');
                $m   = max(0, min(50, (int) ($p['margin'] ?? 0)));
                $ecc = (string) ($p['ecc'] ?? 'M'); if (!in_array($ecc, ['L', 'M', 'Q', 'H'], true)) $ecc = 'M';
                $fmt = ($p['format'] ?? 'png') === 'svg' ? 'svg' : 'png';
                $url = 'https://api.qrserver.com/v1/create-qr-code/?size=' . $s . 'x' . $s . '&data=' . rawurlencode($v)
                    . '&color=' . $fg . '&bgcolor=' . $bg . '&margin=' . $m . '&ecc=' . $ecc . '&format=' . $fmt;
                $fr = '';
                if (($x = self::cssLen($p['framePad'] ?? '')) !== '') $fr .= 'padding:' . $x . ';';
                if (($x = self::cssColor($p['frameBg'] ?? '')) !== '') $fr .= 'background:' . $x . ';';
                if (($x = self::cssLen($p['frameRadius'] ?? '')) !== '') $fr .= 'border-radius:' . $x . ';';
                if (filter_var($p['frameBorder'] ?? false, FILTER_VALIDATE_BOOLEAN)) $fr .= 'border:1px solid var(--ppd-border);';
                $img = '<img class="ppd-qr-img" src="' . self::e($url) . '" width="' . $s . '" height="' . $s . '" alt="QR code" loading="lazy"' . ($fr !== '' ? ' style="' . self::e($fr) . '"' : '') . '>';
                $cap = trim((string) ($p['caption'] ?? '')) !== '' ? '<div class="ppd-qr-cap">' . self::e((string) $p['caption']) . '</div>' : '';
                return '<div class="ppd-qr-box">' . $img . $cap . '</div>';
            }

            case 'search': {
                // A real search launcher: GETs the target table's list page with
                // ?search=<term> (+ ?searchin=<cols> to scope which fields match).
                $t = self::ident($p['table'] ?? '');
                $ph = self::e((string) ($p['placeholder'] ?? 'Search records...'));
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $showBtn = filter_var($p['showButton'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $autofocus = filter_var($p['autofocus'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $btnTxt = self::e((string) ($p['buttonText'] ?? 'Search'));
                if ($t === '') {
                    return '<div class="ppd-c-search pp-search-' . $size . '">' . self::ic('bi-search')
                        . '<input placeholder="' . $ph . '" disabled></div>';
                }
                $fields = array_values(array_filter(array_map([self::class, 'ident'], self::arr($p['searchFields'] ?? []))));
                $searchin = $fields !== [] ? '<input type="hidden" name="searchin" value="' . self::e(implode(',', $fields)) . '">' : '';
                $action = '<?= APP_BASE ?>/generated/' . $t . '/list.php';
                $target = filter_var($p['newTab'] ?? false, FILTER_VALIDATE_BOOLEAN) ? ' target="_blank"' : '';
                return '<form class="ppd-c-search-form pp-search-' . $size . '" method="get" action="' . $action . '"' . $target . ' role="search">'
                    . '<div class="ppd-c-search">' . self::ic('bi-search')
                    . '<input type="search" name="search" placeholder="' . $ph . '"' . ($autofocus ? ' autofocus' : '') . '>'
                    . $searchin . '</div>'
                    . ($showBtn ? '<button type="submit" class="ppd-c-search-btn">' . $btnTxt . '</button>' : '')
                    . '</form>';
            }

            case 'pagination': {
                // A live pager. Dynamic: counts a table (+optional WHERE) and links
                // to that table's list. Manual: a fixed total, paging the current
                // page (?page=N). Reuses the generated app's renderPagination().
                $dyn = ($p['source'] ?? 'dynamic') === 'dynamic';
                $pageSize = max(1, (int) ($p['pageSize'] ?? 10));
                $align = in_array($p['align'] ?? 'left', ['left', 'center', 'right'], true) ? (string) ($p['align'] ?? 'left') : 'left';
                $size = in_array($p['size'] ?? 'md', ['sm', 'md', 'lg'], true) ? (string) ($p['size'] ?? 'md') : 'md';
                $summary = filter_var($p['showSummary'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false ? 'true' : 'false';
                $optCls = trim(($align !== 'left' ? 'pp-pag-' . $align . ' ' : '') . ($size !== 'md' ? 'pp-pag-' . $size : ''));
                $optExpr = "['summary' => $summary, 'class' => " . var_export($optCls, true) . ']';

                if ($dyn && self::ident($p['table'] ?? '') !== '') {
                    $t = self::ident($p['table']);
                    $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
                    $countExpr = $where !== ''
                        ? '"SELECT COUNT(*) FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ')'
                        : '"SELECT COUNT(*) FROM `' . $t . '`"';
                    return "<?php try { \$__pgt = (int) (\\PpPhp\\Core\\DB::FetchOne($countExpr) ?: 0); } catch (\\Throwable \$__e) { \$__pgt = 0; } "
                        . "\$__pgp = max(1, (int) (\$_GET['page'] ?? 1)); "
                        . "echo renderPagination(\$__pgt, \$__pgp, $pageSize, APP_BASE . '/generated/$t/list.php', [], $optExpr); ?>";
                }

                // Manual: paginate the current page, preserving its other query params.
                $total = max(0, (int) ($p['totalRecords'] ?? 0));
                return "<?php \$__pgp = max(1, (int) (\$_GET['page'] ?? 1)); \$__pgq = \$_GET; unset(\$__pgq['page']); "
                    . "echo renderPagination($total, \$__pgp, $pageSize, strtok((string) (\$_SERVER['REQUEST_URI'] ?? ''), '?'), \$__pgq, $optExpr); ?>";
            }

            case 'filter-chips': {
                // Quick-filter chips: each links to the bound table's list with a
                // field filter (?f[col]=val), a search (?search=val), or nothing (an
                // "All" chip that clears). Optional live counts.
                $t = self::ident($p['table'] ?? '');
                $align = in_array($p['align'] ?? 'left', ['left', 'center', 'right'], true) ? (string) ($p['align'] ?? 'left') : 'left';
                $showClear = filter_var($p['showClear'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $showCounts = filter_var($p['showCounts'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $cls = 'ppd-filter-chips pp-fchips-' . $align;
                if ($t === '') {
                    return '<div class="' . $cls . '"><span class="ppd-fchips-label">' . self::ic('bi-funnel') . ' Bind this to a table</span></div>';
                }
                $base = '<?= APP_BASE ?>/generated/' . $t . '/list.php';
                $out = '<div class="' . $cls . '"><span class="ppd-fchips-label">' . self::ic('bi-funnel') . '</span>';
                foreach (self::arr($p['chips'] ?? []) as $chip) {
                    $chip = (array) $chip;
                    $label = trim((string) ($chip['label'] ?? ''));
                    if ($label === '') continue;
                    $field = self::ident($chip['field'] ?? '');
                    $value = (string) ($chip['value'] ?? '');
                    $cnt = $showCounts ? self::filterCountHtml($t, $field, $value) : '';
                    $out .= '<a class="ppd-fchip" href="' . self::filterHref($base, $field, $value) . '">' . self::e($label) . $cnt . '</a>';
                }
                if ($showClear) {
                    $out .= '<a class="ppd-fchips-clear" href="' . $base . '">' . self::e('Clear all') . '</a>';
                }
                return $out . '</div>';
            }

            case 'where-tabs': {
                // Segmented filter tabs. Each tab is a view of the bound table
                // (?f[col]=val / ?search=val); the matching tab shows active, and an
                // "All data" tab clears. Optional live record counts.
                $t = self::ident($p['table'] ?? '');
                $showCounts = filter_var($p['showCounts'] ?? false, FILTER_VALIDATE_BOOLEAN);
                $showAll = filter_var($p['showAll'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
                $size = in_array($p['size'] ?? 'normal', ['normal', 'compact', 'large'], true) ? (string) ($p['size'] ?? 'normal') : 'normal';
                $cls = 'ppd-c-where-tabs pp-wt-' . $size;
                $tabs = self::arr($p['tabs'] ?? []);
                if ($t === '' || $tabs === []) {
                    return '<div class="' . $cls . '"><span class="ppd-wt-hint">' . self::ic('bi-info-circle') . ' Bind a table and add tabs</span></div>';
                }
                $base = '<?= APP_BASE ?>/generated/' . $t . '/list.php';
                $out = '<div class="' . $cls . '">';
                foreach ($tabs as $tab) {
                    $tab = (array) $tab;
                    $label = trim((string) ($tab['label'] ?? ''));
                    if ($label === '') continue;
                    $field = self::ident($tab['field'] ?? '');
                    $value = (string) ($tab['value'] ?? '');
                    $active = '<?php echo ' . self::filterActiveExpr($field, $value) . ' ? " ppd-wt-active" : ""; ?>';
                    $cnt = $showCounts ? self::filterCountHtml($t, $field, $value) : '';
                    $out .= '<a class="ppd-wt-tab' . $active . '" href="' . self::filterHref($base, $field, $value) . '">' . self::e($label) . $cnt . '</a>';
                }
                if ($showAll) {
                    $allActive = '<?php echo ' . self::filterActiveExpr('', '') . ' ? " ppd-wt-active" : ""; ?>';
                    $allCnt = $showCounts ? self::filterCountHtml($t, '', '') : '';
                    $out .= '<a class="ppd-wt-tab' . $allActive . '" href="' . $base . '">' . self::e('All data') . $allCnt . '</a>';
                }
                return $out . '</div>';
            }

            case 'custom-button': {
                // 3-way event button: Client Before (JS) → Server (PHP) → Client
                // After (JS). The JS halves ride along as base64 data-attrs and run
                // client-side; the PHP half is baked into api/button.php (collected
                // at build) and called over the built-in CSRF-guarded JSON bridge.
                $variant = (string) ($p['variant'] ?? 'primary');
                $vc = $variant === 'primary' ? ' primary' : ($variant === 'danger' ? ' danger' : '');
                $ic = (string) ($p['ico'] ?? $p['icon'] ?? 'bi-lightning');
                $bl = '<span>' . self::e((string) ($p['label'] ?? 'Custom action')) . '</span>';
                $tip = trim((string) ($p['tooltip'] ?? ''));
                $titleAttr = $tip !== '' ? ' title="' . self::e($tip) . '"' : '';
                $bid = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($c['id'] ?? ''));
                $before = (string) ($p['clientBefore'] ?? '');
                $after  = (string) ($p['clientAfter'] ?? '');
                $server = trim((string) ($p['server'] ?? ''));
                $a = ' data-ppcb="' . self::e($bid) . '"';
                if (trim($before) !== '') $a .= ' data-ppcb-before="' . base64_encode($before) . '"';
                if (trim($after) !== '')  $a .= ' data-ppcb-after="' . base64_encode($after) . '"';
                if ($server !== '') {
                    // API URL + CSRF token are echoed by PHP when the page runs
                    // (component HTML executes as PHP, same as appUrl's APP_BASE).
                    $a .= ' data-ppcb-server="1"'
                        . ' data-ppcb-api="<?= APP_BASE ?>/api/button.php"'
                        . ' data-ppcb-csrf="<?= htmlspecialchars(\PpPhp\Core\Security::CSRF()->Token(), ENT_QUOTES) ?>"';
                }
                return self::customButtonRuntime()
                    . '<button type="button" class="pp-btn-form' . $vc . '"' . $titleAttr . $a . '>' . self::ic($ic) . $bl . '</button>';
            }

            case 'button':
                $variant = (string) ($p['variant'] ?? 'default');
                $vc = $variant === 'primary' ? ' primary' : ($variant === 'danger' ? ' danger' : '');
                $ic = (string) ($p['ico'] ?? $p['icon'] ?? 'bi-lightning');
                $bl = '<span>' . self::e((string) ($p['label'] ?? 'Button')) . '</span>';
                $tip = trim((string) ($p['tooltip'] ?? ''));
                $titleAttr = $tip !== '' ? ' title="' . self::e($tip) . '"' : '';
                $action = trim((string) ($p['action'] ?? ''));
                // "js: <code>" runs author JavaScript on click (author-trusted, same
                // trust model as the JS/PHP snippet components).
                if (stripos($action, 'js:') === 0) {
                    return '<button type="button" class="pp-btn-form' . $vc . '"' . $titleAttr . ' onclick="' . self::e(trim(substr($action, 3))) . '">' . self::ic($ic) . $bl . '</button>';
                }
                // A page path or URL (from Action, or a legacy href/url) turns the
                // button into a link that still looks like a button — needed for
                // navigational pages (menu / landing).
                $bh = $action !== '' ? $action : trim((string) ($p['href'] ?? $p['url'] ?? ''));
                if ($bh !== '') {
                    $href = ($bh[0] ?? '') === '#' ? self::e($bh) : self::appUrl($bh);   // pure #anchors pass through
                    return '<a class="pp-btn-form' . $vc . '" href="' . $href . '"' . $titleAttr . '>' . self::ic($ic) . $bl . '</a>';
                }
                return '<button type="button" class="pp-btn-form' . $vc . '"' . $titleAttr . '>' . self::ic($ic) . $bl . '</button>';

            case 'row':
                // Multi-column layout row — SAME classes as the canvas so the
                // columns render identically in the generated app.
                $cols = (int) ($p['columns'] ?? 2);
                if ($cols < 1 || $cols > 3) $cols = 2;
                return '<div class="ppd-row cols-' . $cols . '">' . $renderChildren($c) . '</div>';
            case 'single_column':
                return '<div class="ppd-lay ppd-lay-1">' . $renderChildren($c) . '</div>';
            case 'two_column':
                return '<div class="ppd-lay ppd-lay-2">' . $renderChildren($c) . '</div>';
            case 'column':
                return '<div class="ppd-lay ppd-lay-col">' . $renderChildren($c) . '</div>';
            case 'kpi_grid':
                return '<div class="ppd-lay ppd-lay-kpi">' . $renderChildren($c) . '</div>';
            case 'analytics_grid':
                return '<div class="ppd-lay ppd-lay-analytics">' . $renderChildren($c) . '</div>';
            case 'grid':
            case 'cell':
            case 'card-grid':
                // Other containers — just render their children.
                return $renderChildren($c);
        }

        // Unknown / unsupported component: render children if any, else nothing.
        $kids = $renderChildren($c);
        return $kids !== '' ? $kids : '';
    }

    /**
     * Analog clock for the hero.
     *
     * The hands are driven by pure CSS animation with a NEGATIVE animation-delay
     * equal to the elapsed time, so a single calculation at load produces a
     * perfectly smooth sweep forever. The reference implementation ran a
     * requestAnimationFrame loop every frame for the life of the page; this
     * costs nothing per frame and the compositor can handle it off-thread.
     *
     * Every colour is a theme token — no prefers-color-scheme, which would
     * ignore the app's own theme.
     */
    private static function heroClock(array $p = []): string
    {
        // The hero dial IS the standalone Clock component (responsive .ppc-*, one
        // clock system everywhere). It is tinted for the dark hero purely via CSS
        // (.pp-hero-clock .pp-clock-widget); the greeting's `clk*` props let the
        // designer customize it (size, face, hands, centre, chips, symbols).
        return '<div class="pp-hero-clock" aria-hidden="true">'
            . self::clockWidget(self::heroClockProps($p))
            . '</div>';
    }

    /**
     * Map a greeting node's `clk*` props → Clock component props, applying the
     * hero defaults. Mirrors heroClockProps() in registry.js so the built app
     * matches the Designer canvas exactly.
     */
    private static function heroClockProps(array $p): array
    {
        $b = static function (string $k, bool $d) use ($p): bool {
            if (!array_key_exists($k, $p)) return $d;
            $v = $p[$k];
            return $v === true || $v === 'yes' || $v === 'true' || $v === 1 || $v === '1';
        };
        $s = static function (string $k) use ($p): string {
            return isset($p[$k]) ? (string) $p[$k] : '';
        };
        $d = static function (string $k, string $dv) use ($p): string {
            return (isset($p[$k]) && $p[$k] !== '') ? (string) $p[$k] : $dv;
        };
        return [
            'size' => ((int) ($p['clkSize'] ?? 150)) ?: 150,
            'align' => 'center',
            'transparent' => $b('clkTransparent', false),
            'showHour' => $b('clkShowHour', true),
            'showMin'  => $b('clkShowMin', true),
            'showSec'  => $b('clkShowSec', true),
            'showDay'   => $b('clkShowDay', true),
            'showDate'  => $b('clkShowDate', true),
            'showEmoji' => $b('clkShowEmoji', true),
            'hourColor' => $s('clkHourColor'),
            'minColor'  => $s('clkMinColor'),
            'secColor'  => $s('clkSecColor'),
            'tickColor' => $s('clkTickColor'),
            'faceColor'  => $s('clkFaceColor'),
            'faceColor2' => $s('clkFaceColor2'),
            'faceStyle'  => $d('clkFaceStyle', 'solid'),
            'centerType'   => $d('clkCenterType', 'none'),
            'image'        => $s('clkImage'),
            'text'         => $s('clkText'),
            'centerBorder' => $b('clkCenterBorder', true),
            'textColor'    => $s('clkTextColor'),
            'centerSize'   => $p['clkCenterSize'] ?? 48,
            'emMorning'   => $p['clkEmMorning']   ?? '🌅',
            'emAfternoon' => $p['clkEmAfternoon'] ?? '☀️',
            'emEvening'   => $p['clkEmEvening']   ?? '🌇',
            'emNight'     => $p['clkEmNight']     ?? '🌙',
        ];
    }

    /**
     * Decorative hero illustration. Pure inline SVG + CSS animation: nothing to
     * download, and every colour comes from the theme via currentColor.
     */
    private static function heroArt(): string
    {
        return '<div class="pp-hero-art" aria-hidden="true">'
            . '<svg viewBox="0 0 220 170" role="presentation" focusable="false">'
            // soft orb behind the stack
            . '<circle class="pa-orb" cx="120" cy="80" r="58" />'
            // three drifting cards
            . '<g class="pa-card pa-c3"><rect x="34" y="86" width="118" height="52" rx="10"/>'
            . '<rect class="pa-line" x="46" y="100" width="52" height="7" rx="3.5"/>'
            . '<rect class="pa-line dim" x="46" y="114" width="82" height="6" rx="3"/></g>'
            . '<g class="pa-card pa-c2"><rect x="54" y="52" width="118" height="52" rx="10"/>'
            . '<rect class="pa-line" x="66" y="66" width="44" height="7" rx="3.5"/>'
            . '<rect class="pa-line dim" x="66" y="80" width="76" height="6" rx="3"/></g>'
            . '<g class="pa-card pa-c1"><rect x="74" y="18" width="118" height="52" rx="10"/>'
            . '<rect class="pa-line" x="86" y="32" width="58" height="7" rx="3.5"/>'
            . '<rect class="pa-line dim" x="86" y="46" width="68" height="6" rx="3"/></g>'
            // rising sparkles
            . '<circle class="pa-dot d1" cx="28" cy="60" r="4"/>'
            . '<circle class="pa-dot d2" cx="196" cy="120" r="5"/>'
            . '<circle class="pa-dot d3" cx="180" cy="24" r="3"/>'
            . '</svg></div>';
    }

    /**
     * Local-time correction for the hero. Guarded so several heroes on one page
     * still only install it once.
     */
    private static function heroScript(): string
    {
        return '<script>(function(){if(window.__ppHero)return;window.__ppHero=1;'
            . 'var f=function(){'
            . 'var h=new Date().getHours();'
            . 'var g=h<5?"Good night":(h<12?"Good morning":(h<17?"Good afternoon":(h<21?"Good evening":"Good night")));'
            . 'document.querySelectorAll("[data-pp-greet]").forEach(function(e){e.textContent=g});'
            . 'document.querySelectorAll("[data-pp-today] span").forEach(function(e){'
            . 'e.textContent=new Date().toLocaleDateString(undefined,{weekday:"long",day:"numeric",month:"long",year:"numeric"})});'
            // Clock chips: day, date and a time-of-day emoji.
            . 'var D=new Date(),dn=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][D.getDay()];'
            . 'var eh=D.getHours(),em=eh>=5&&eh<12?"\u{1F305}":(eh>=12&&eh<17?"\u2600\uFE0F":(eh>=17&&eh<20?"\u{1F307}":"\u{1F319}"));'
            . 'document.querySelectorAll("[data-pp-clock-day]").forEach(function(e){e.textContent=dn});'
            . 'document.querySelectorAll("[data-pp-clock-date]").forEach(function(e){e.textContent=D.getDate()});'
            . 'document.querySelectorAll("[data-pp-clock-emoji]").forEach(function(e){e.textContent=em});'
            . '};'
            // Hands: ONE calculation, then CSS animates them forever. A negative
            // delay equal to the elapsed time positions each hand exactly.
            . 'var hands=function(){var d=new Date();'
            . 'var s=d.getSeconds()+d.getMilliseconds()/1000, m=d.getMinutes()*60+s, h=(d.getHours()%12)*3600+m;'
            . 'document.querySelectorAll(".pp-clk").forEach(function(c){'
            . 'c.style.setProperty("--sd",(-s)+"s");c.style.setProperty("--md",(-m)+"s");c.style.setProperty("--hd",(-h)+"s")});'
            . '};hands();'
            . 'if(document.readyState!=="loading")f();else document.addEventListener("DOMContentLoaded",f);'
            // keep it honest if the tab is left open across midnight / noon
            . 'setInterval(f,60000);'
            . '})();</script>';
    }

    /**
     * The standalone Clock component. Independent of the hero: its own .ppc-*
     * markup and per-instance CSS vars. MUST stay byte-for-byte in step with the
     * Designer canvas render (registry.js `def('clock')`) so a built app matches
     * the preview exactly. Supports: size / alignment / transparent dial; float-
     * on-top overlay (overlap + horizontal nudge); an image OR text centre sized
     * by one slider; per-hand visibility; hand + tick + face colours/gradient; and
     * per-time-of-day symbols. Structure is static; clockWidgetScript() positions
     * the hands and fills day/date/symbol from the viewer's LOCAL time (the server
     * is UTC, so this must happen in the browser — same reason as the hero).
     *
     * @param array<string,mixed> $p
     */
    private static function clockWidget(array $p): string
    {
        $size  = max(60, min(600, (int) ($p['size'] ?? 220)));
        $align = (string) ($p['align'] ?? 'center');
        if (!in_array($align, ['left', 'center', 'right'], true)) $align = 'center';
        // Centre size falls back to the legacy imageSize so older clocks keep theirs.
        $centerSize = max(0, min(95, (int) ($p['centerSize'] ?? $p['imageSize'] ?? 48)));
        $centerType = (string) ($p['centerType'] ?? (!empty($p['image']) ? 'image' : 'none'));
        $transp  = ($p['transparent'] ?? false) === true || ($p['transparent'] ?? '') === 'yes';
        $overlay = ($p['overlay'] ?? false) === true || ($p['overlay'] ?? '') === 'yes';

        $sty = '--clk:' . $size . 'px;--clk-center:' . $centerSize . 'cqmin;';
        foreach (['hourColor' => '--clk-h', 'minColor' => '--clk-m', 'secColor' => '--clk-s', 'tickColor' => '--clk-tick', 'textColor' => '--clk-text'] as $k => $var) {
            $c = self::cssColor($p[$k] ?? '');
            if ($c !== '') $sty .= $var . ':' . $c . ';';
        }
        $fbg = self::clockFaceBg($p);
        if ($fbg !== '') $sty .= '--clk-face-bg:' . $fbg . ';';
        if ($overlay) $sty .= '--clk-overlap:' . max(0, (int) ($p['overlap'] ?? 0)) . 'px;--clk-ox:' . (int) ($p['offsetX'] ?? 0) . 'px;';

        $cls = 'pp-clock-widget ppc-al-' . $align . ($overlay ? ' is-overlay' : '')
            . (($p['showHour'] ?? true) === false ? ' no-h' : '')
            . (($p['showMin'] ?? true) === false ? ' no-m' : '')
            . (($p['showSec'] ?? true) === false ? ' no-s' : '');

        $ticks = '';
        for ($i = 0; $i < 12; $i++) {
            $ticks .= '<span class="ppc-tick' . ($i % 3 === 0 ? ' major' : '') . '" style="--t:' . ($i * 30) . 'deg"></span>';
        }
        $center = '';
        if ($centerType === 'image' && !empty($p['image'])) {
            $noBd = ($p['centerBorder'] ?? true) === false || ($p['centerBorder'] ?? '') === 'no';
            $center = '<img class="ppc-center ppc-center-img' . ($noBd ? ' no-bd' : '') . '" src="' . self::e((string) $p['image']) . '" alt="">';
        } elseif ($centerType === 'text' && trim((string) ($p['text'] ?? '')) !== '') {
            $center = '<span class="ppc-center ppc-center-text">' . self::e((string) $p['text']) . '</span>';
        }
        $emoji = ($p['showEmoji'] ?? true) !== false ? '<span class="ppc-emoji" data-ppc-emoji></span>' : '';
        $day   = ($p['showDay'] ?? true) !== false ? '<span class="ppc-chip ppc-day" data-ppc-day></span>' : '';
        $date  = ($p['showDate'] ?? true) !== false ? '<span class="ppc-chip ppc-date" data-ppc-date></span>' : '';
        // Per-clock time-of-day symbols; the script picks + resolves (emoji / bi-icon / image URL).
        $ed = ' data-em-m="' . self::e((string) ($p['emMorning'] ?? '🌅')) . '"'
            . ' data-em-a="' . self::e((string) ($p['emAfternoon'] ?? '☀️')) . '"'
            . ' data-em-e="' . self::e((string) ($p['emEvening'] ?? '🌇')) . '"'
            . ' data-em-n="' . self::e((string) ($p['emNight'] ?? '🌙')) . '"';

        return '<div class="' . $cls . '" style="' . $sty . '">'
            . '<div class="ppc-clk' . ($transp ? ' is-transparent' : '') . '"' . $ed . '>'
            . '<div class="ppc-face">'
            . $ticks . $center . $emoji . $day . $date
            . '<span class="ppc-hand h"></span><span class="ppc-hand m"></span><span class="ppc-hand s"></span>'
            . '<span class="ppc-pin"></span>'
            . '</div></div>'
            . self::clockWidgetScript()
            . '</div>';
    }

    /** Dial-face background: solid colour or a 2-colour linear/radial gradient. */
    private static function clockFaceBg(array $p): string
    {
        $c1 = self::cssColor($p['faceColor'] ?? '');
        if ($c1 === '') return '';
        $c2 = self::cssColor($p['faceColor2'] ?? '');
        if ($c2 === '') $c2 = $c1;
        $style = (string) ($p['faceStyle'] ?? 'solid');
        if ($style === 'linear') return 'linear-gradient(160deg,' . $c1 . ',' . $c2 . ')';
        if ($style === 'radial') return 'radial-gradient(circle at 50% 35%,' . $c1 . ',' . $c2 . ')';
        return $c1;
    }

    /** One-time script that drives every .ppc-clk on the page (viewer-local time). */
    private static function clockWidgetScript(): string
    {
        return '<script>(function(){if(window.__ppClkW)return;window.__ppClkW=1;'
            // A symbol may be an emoji/text, a bi-* icon, or an image URL.
            . 'function em(v){v=(v||"").trim();if(!v)return "";'
            . 'if(/^https?:\\/\\//i.test(v))return "<img src=\\""+v.replace(/"/g,"&quot;")+"\\" alt=\\"\\">";'
            . 'if(/^bi-[a-z0-9-]+$/i.test(v))return "<i class=\\"bi "+v+"\\"></i>";'
            . 'return v.replace(/</g,"&lt;");}'
            // Hands: ONE calculation via negative animation-delay, then CSS spins.
            . 'var hands=function(){var d=new Date();'
            . 'var s=d.getSeconds()+d.getMilliseconds()/1000,m=d.getMinutes()*60+s,h=(d.getHours()%12)*3600+m;'
            . 'document.querySelectorAll(".ppc-clk").forEach(function(c){'
            . 'c.style.setProperty("--sd",(-s)+"s");c.style.setProperty("--md",(-m)+"s");c.style.setProperty("--hd",(-h)+"s")})};'
            . 'var chips=function(){var d=new Date(),dn=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()],eh=d.getHours();'
            . 'document.querySelectorAll("[data-ppc-day]").forEach(function(e){e.textContent=dn});'
            . 'document.querySelectorAll("[data-ppc-date]").forEach(function(e){e.textContent=d.getDate()});'
            // Per-clock symbol: pick morning/afternoon/evening/night from its data-* set.
            . 'document.querySelectorAll(".ppc-clk").forEach(function(c){var e=c.querySelector("[data-ppc-emoji]");if(!e)return;'
            . 'var v=eh>=5&&eh<12?c.getAttribute("data-em-m"):(eh>=12&&eh<17?c.getAttribute("data-em-a"):(eh>=17&&eh<20?c.getAttribute("data-em-e"):c.getAttribute("data-em-n")));'
            . 'e.innerHTML=em(v)})};'
            . 'var run=function(){hands();chips()};'
            . 'if(document.readyState!=="loading")run();else document.addEventListener("DOMContentLoaded",run);'
            . 'setInterval(chips,60000);'
            . '})();</script>';
    }

    /**
     * Resolve a component url. Absolute (http, //, mailto) passes through;
     * anything else is emitted as an APP_BASE-relative PHP expression so the
     * generated page works under a sub-directory install.
     */
    private static function appUrl(string $url): string
    {
        $u = trim($url);
        if ($u === '') return '#';
        // Escape here — callers must NOT re-escape, or the PHP tag below would
        // be turned into entities and the link would break.
        if (preg_match('~^(https?:)?//|^(mailto|tel):~i', $u)) return self::e($u);
        return '<?= APP_BASE ?>' . self::e(str_starts_with($u, '/') ? $u : '/' . $u);
    }

    /** Component types this renderer produces meaningful output for. */
    public static function handles(string $type): bool
    {
        static $set = null;
        if ($set === null) {
            $set = array_flip([
                'card', 'menu-card', 'menu-group', 'field-group', 'layout-grid', 'divider', 'alert', 'heading', 'text', 'image', 'navbar', 'hero', 'feature-grid', 'footer', 'pricing', 'testimonials', 'contact-form', 'announcement-bar', 'logo-cloud', 'stats-counter', 'cta-band', 'team-grid', 'faq', 'carousel', 'gallery', 'countdown', 'marquee', 'social-links', 'comparison-table', 'back-to-top', 'cookie-consent', 'newsletter', 'feature-split', 'bento-grid', 'video-embed', 'post-grid', 'checklist', 'link-list', 'icon', 'video', 'iframe', 'custom-map',
                'html', 'code-snippet', 'css-snippet', 'js-snippet', 'php-snippet', 'tabs', 'accordion', 'modal', 'offcanvas',
                'status-badge', 'toast', 'progress', 'progress-card', 'rating', 'avatar', 'stat-card', 'kpi-row', 'greeting', 'clock', 'group-box',
                'kpi-trend-card', 'timeline', 'quick-links', 'task-list', 'totals', 'summary', 'wizard-step', 'external-link',
                'recent-activity', 'notification-list', 'mini-table', 'calendar-widget', 'calendar', 'kanban', 'section',
                'download-link', 'signature', 'qr-code', 'button', 'custom-button', 'search', 'pagination', 'filter-chips', 'where-tabs',
                'button-group', 'workflow-steps',
                'row', 'column', 'grid', 'single_column', 'two_column', 'cell', 'card-grid',
                'kpi_grid', 'analytics_grid', 'kpi-stat', 'chart', 'detail-grid', 'ai-assist', 'cause-list', 'deadline-tracker', 'doc-generator', 'fee-calculator', 'limitation-calculator', 'daily-cause-list', 'activity-timeline', 'portal-link',
            ]);
        }
        return isset($set[$type]);
    }

    private static function ic(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 '<i class="' . (str_contains($name, 'fa-solid') || str_contains($name, 'fa-regular') ? $name : 'fa-solid ' . $name) . '"></i>';
        }
        return '<i class="bi ' . (str_starts_with($name, 'bi-') ? $name : 'bi-' . $name) . '"></i>';
    }

    /** Sanitize a CSS colour value (hex / rgb / hsl / named) — never markup. */
    public static function cssColor(mixed $v): string
    {
        $v = trim((string) $v);
        return preg_match('/^#[0-9a-fA-F]{3,8}$|^(rgb|rgba|hsl|hsla)\([0-9%,.\/ ]+\)$|^[a-zA-Z-]{1,24}$/', $v) ? $v : '';
    }

    /** Sanitize a CSS length / shorthand (e.g. 12px, .5rem, 6px 10px). */
    public static function cssLen(mixed $v): string
    {
        $v = trim((string) $v);
        return preg_match('/^[0-9a-zA-Z.% ]{1,32}$/', $v) ? $v : '';
    }

    /**
     * Inline CSS for one composite-widget sub-element (Stat Card value, Hero
     * title…). MUST mirror registry.js partStyleCss so the Designer canvas and
     * the generated app render the sub-element identically. Every value is run
     * through a sanitizer so a style bag can never break out of the attribute.
     */
    public static function partStyleCss(array $bag): string
    {
        $out = '';
        if (($c = self::cssColor($bag['color'] ?? '')) !== '') $out .= 'color:' . $c . ';';
        if (($c = self::cssColor($bag['background'] ?? '')) !== '') $out .= 'background:' . $c . ';';
        $font = preg_replace('/[^a-zA-Z0-9_,\- "\']/', '', (string) ($bag['font'] ?? ''));
        if (trim($font) !== '') $out .= 'font-family:' . $font . ';';
        if (($l = self::cssLen($bag['fontSize'] ?? '')) !== '') $out .= 'font-size:' . $l . ';';
        $fw = (string) ($bag['fontWeight'] ?? '');
        if (in_array($fw, ['normal', '500', '600', '700', '800'], true)) $out .= 'font-weight:' . $fw . ';';
        $al = (string) ($bag['align'] ?? '');
        if (in_array($al, ['left', 'center', 'right'], true)) $out .= 'text-align:' . $al . ';';
        if (($l = self::cssLen($bag['padding'] ?? '')) !== '') $out .= 'padding:' . $l . ';';
        return $out;
    }

    /**
     * class + style attribute string for a sub-element: merges the base class,
     * the user's optional CSS class, and the inline part style. Mirrors the
     * canvas elAttrs (minus the .ppd-part selection hooks, which are canvas-only).
     */
    public static function partAttr(array $p, string $key, string $baseCls = ''): string
    {
        return self::partAttrFrom($baseCls, (array) (($p['partStyles'] ?? [])[$key] ?? []));
    }

    /**
     * The Custom Button client runtime — a single delegated click handler that
     * drives the 3-way flow: Client Before (JS) → optional Server (PHP over the
     * CSRF-guarded api/button.php bridge) → Client After (JS). Emitted next to
     * each custom button but guarded by `window.__ppcbInit` so it installs once
     * per page regardless of how many buttons ship it (ComponentRenderer is
     * stateless across pages, so a per-button emit is the reliable way to be sure
     * every page that has a custom button also has its runtime).
     */
    /**
     * Self-contained Tabs runtime — the variant CSS + a vanilla-JS click handler
     * (event-delegated on document, so it also covers tabs inserted later). Emitted
     * once per page, so Tabs switch on ANY page without depending on app.js/jQuery.
     */
    public static function tabsRuntime(): string
    {
        static $out = null;
        if ($out === null) {
            // Vanilla, event-delegated on document — covers tabs added later too, and
            // needs no app.js/jQuery. Variant styling lives in the component CSS.
            $js = '(function(){if(window.__ppTabsInit)return;window.__ppTabsInit=1;'
                . 'document.addEventListener("click",function(e){'
                . 'var b=e.target.closest(".ppd-c-tabs [data-pptab]");if(!b)return;var box=b.closest(".ppd-c-tabs");if(!box)return;'
                . 'var i=b.getAttribute("data-pptab");'
                . 'box.querySelectorAll(":scope > .ppd-tabbar > [data-pptab]").forEach(function(x){x.classList.toggle("active",x.getAttribute("data-pptab")===i);});'
                . 'box.querySelectorAll(":scope > [data-pptabpane]").forEach(function(p){var on=p.getAttribute("data-pptabpane")===i;p.classList.toggle("active",on);if(on){p.removeAttribute("hidden");}else{p.setAttribute("hidden","hidden");}});'
                . '});})();';
            $out = '<script>' . $js . '</script>';
        }
        return $out;
    }

    public static function customButtonRuntime(): string
    {
        static $js = null;
        if ($js === null) {
            // Make the pp.js client API (pp.dialog / pp.ajax / pp.util) available to
            // the author's button JS even on lean pages that don't load app.js
            // (custom / shell pages) — inject it once if it isn't already present.
            $js = '<script>(function(){'
                . 'if(!window.pp&&!window.__ppJsReq){window.__ppJsReq=1;var s=document.createElement("script");s.src="<?= APP_BASE ?>/assets/js/pp.js";s.defer=true;document.head.appendChild(s);}'
                . 'if(window.__ppcbInit)return;window.__ppcbInit=1;'
                . 'function dec(b){try{return decodeURIComponent(escape(atob(b)))}catch(e){try{return atob(b)}catch(_){return ""}}}'
                . 'document.addEventListener("click",function(ev){'
                . 'var b=ev.target.closest("[data-ppcb]");if(!b||b.disabled)return;ev.preventDefault();'
                . 'var before=b.getAttribute("data-ppcb-before"),after=b.getAttribute("data-ppcb-after");'
                . 'var params={};'
                . 'if(before){try{if((new Function("params","btn",dec(before))).call(b,params,b)===false)return;}catch(e){console.error("Custom Button — Client Before:",e);return;}}'
                . 'var done=function(result){if(after){try{(new Function("params","result","btn",dec(after))).call(b,params,result,b);}catch(e){console.error("Custom Button — Client After:",e);}}};'
                . 'if(b.getAttribute("data-ppcb-server")!=="1"){done({});return;}'
                . '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=Runner.getSelectedKeys();}catch(e){}'
                . 'var api=b.getAttribute("data-ppcb-api"),csrf=b.getAttribute("data-ppcb-csrf")||"";'
                . 'b.classList.add("is-busy");b.disabled=true;'
                . 'fetch(api,{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":csrf},credentials:"same-origin",body:JSON.stringify({btn:b.getAttribute("data-ppcb"),params:params})})'
                . '.then(function(r){return r.json();})'
                . '.then(function(j){b.classList.remove("is-busy");b.disabled=false;if(j&&j.commands&&window.Runner)window.Runner.apply(j.commands);if(j&&j.success===false&&j.error)console.error("Custom Button — Server:",j.error);done(j&&j.result!==undefined?j.result:(j||{}));})'
                . '.catch(function(e){b.classList.remove("is-busy");b.disabled=false;console.error("Custom Button — Server:",e);done({error:"request failed"});});'
                . '});})();</script>';
        }
        return $js;
    }

    /** Same as partAttr but from an explicit bag (lets a case merge a legacy prop). */
    public static function partAttrFrom(string $baseCls, array $bag): string
    {
        $cls = trim($baseCls);
        $userCls = trim((string) preg_replace('/[^a-zA-Z0-9_ -]/', '', (string) ($bag['class'] ?? '')));
        if ($userCls !== '') $cls = trim($cls . ' ' . $userCls);
        $css = self::partStyleCss($bag);
        return ($cls !== '' ? ' class="' . self::e($cls) . '"' : '')
            . ($css !== '' ? ' style="' . self::e($css) . '"' : '');
    }

    /**
     * Count-badge CSS for a size (sm/md/lg) + shape (pill/rounded/square/
     * circle) — the PHP mirror of the canvas mdBadgeCss so the generated app
     * matches the Designer exactly.
     */
    public static function badgeCss(string $size, string $shape, string $bg, string $color): string
    {
        $dims = ['sm' => [9, 15], 'md' => [10, 19], 'lg' => [12, 25]][$size] ?? [10, 19];
        $css = 'background:' . (self::cssColor($bg) ?: '#64748b') . ';color:' . (self::cssColor($color) ?: '#fff')
            . ';font-size:' . $dims[0] . 'px;';
        if ($shape === 'circle') {
            $css .= 'width:' . $dims[1] . 'px;height:' . $dims[1] . 'px;min-width:0;padding:0;border-radius:50%;'
                . 'display:inline-flex;align-items:center;justify-content:center;';
        } else {
            $css .= 'padding:' . ($size === 'sm' ? '0 5px' : ($size === 'lg' ? '2px 8px' : '1px 6px'))
                . ';border-radius:' . ($shape === 'pill' ? '999px' : ($shape === 'square' ? '0' : '4px')) . ';';
        }
        return $css;
    }

    /**
     * Shared Master–Detail section styling (used by the detail-grid component
     * and the list-page preview registry, so both surfaces look identical and
     * respect the theme). Returns pre-computed inline-CSS + class fragments.
     *
     * @param array<string,mixed> $s  keys: bg, border, radius, pad, headerBg,
     *        headerColor, headerIcon, hideHeader, striped, compact
     * @return array{section:string, header:string, headerColor:string, headerIcon:string, hideHeader:bool, gridClass:string}
     */
    public static function detailSectionCss(array $s): array
    {
        $sec = '';
        if (($c = self::cssColor($s['bg'] ?? '')) !== '')     $sec .= 'background:' . $c . ';';
        if (($c = self::cssColor($s['border'] ?? '')) !== '') $sec .= 'border-color:' . $c . ';';
        if (($l = self::cssLen($s['radius'] ?? '')) !== '')   $sec .= 'border-radius:' . $l . ';';
        if (($l = self::cssLen($s['pad'] ?? '')) !== '')      $sec .= 'padding:' . $l . ';';

        $hdr = '';
        if (($c = self::cssColor($s['headerBg'] ?? '')) !== '') {
            $hdr .= 'background:' . $c . ';border-radius:8px;padding:6px 10px;margin:-2px -2px 8px;';
        }
        $hcol = ($c = self::cssColor($s['headerColor'] ?? '')) !== '' ? 'color:' . $c . ';' : '';

        $gridCls = trim((!empty($s['striped']) ? 'pp-striped ' : '') . (!empty($s['compact']) ? 'pp-compact ' : '') . (!empty($s['hover']) ? 'pp-hover' : ''));

        return [
            'section'     => $sec,
            'header'      => $hdr,
            'headerColor' => $hcol,
            'headerIcon'  => trim((string) ($s['headerIcon'] ?? '')),
            'hideHeader'  => !empty($s['hideHeader']),
            'gridClass'   => $gridCls,
        ];
    }

    /** @return array<int,mixed> */
    private static function arr(mixed $v): array
    {
        if (is_array($v)) return array_values($v);
        return [];
    }

    private static function pct(mixed $v): int
    {
        return max(0, min(100, (int) $v));
    }

    private static function css(string $v): string
    {
        return trim(str_replace(['{', '}', ';', '"', '<', '>'], '', $v));
    }

    /**
     * Build a Menu Card zone's inline style (background / colour / font / size /
     * align) from `<pre>Bg|Color|Font|Size|Align` props. Mirrors mcZoneStyle()
     * in registry.js; every value is sanitized.
     */
    private static function mcZone(array $p, string $pre): string
    {
        $s  = '';
        $bg = self::cssColor($p[$pre . 'Bg'] ?? '');    if ($bg !== '') $s .= 'background:' . $bg . ';';
        $co = self::cssColor($p[$pre . 'Color'] ?? '');  if ($co !== '') $s .= 'color:' . $co . ';';
        $fn = self::css((string) ($p[$pre . 'Font'] ?? '')); if ($fn !== '') $s .= 'font-family:' . $fn . ';';
        $sz = trim((string) ($p[$pre . 'Size'] ?? ''));
        if ($sz !== '') { $sz = self::cssLen(preg_match('/^\d+(\.\d+)?$/', $sz) ? $sz . 'px' : $sz); if ($sz !== '') $s .= 'font-size:' . $sz . ';'; }
        $al = (string) ($p[$pre . 'Align'] ?? '');
        if (in_array($al, ['left', 'center', 'right'], true)) $s .= 'text-align:' . $al . ';';
        return $s;
    }

    /** Menu Card border + radius + shadow inline style. Mirrors mcCardStyle() in registry.js. */
    private static function mcCardStyle(array $p): string
    {
        static $shadows = [
            'none' => 'none',
            'soft' => '0 4px 16px rgba(2,6,23,.07)',
            'medium' => '0 10px 26px rgba(2,6,23,.13)',
            'strong' => '0 18px 44px rgba(2,6,23,.20)',
        ];
        $s  = '';
        $bs = (string) ($p['cardBorderStyle'] ?? '');
        if ($bs === 'none') {
            $s .= 'border:none;';
        } elseif (in_array($bs, ['solid', 'dashed', 'dotted'], true)) {
            $w = self::cssLen((string) ($p['cardBorderWidth'] ?? '')) ?: '1px';
            $c = self::cssColor($p['cardBorderColor'] ?? '') ?: '#e2e8f0';
            $s .= 'border:' . $w . ' ' . $bs . ' ' . $c . ';';
        }
        $r = trim((string) ($p['cardRadius'] ?? ''));
        if ($r !== '') { $r = self::cssLen(preg_match('/^\d+(\.\d+)?$/', $r) ? $r . 'px' : $r); if ($r !== '') $s .= 'border-radius:' . $r . ';'; }
        $sh = (string) ($p['cardShadow'] ?? '');
        if ($sh !== '' && isset($shadows[$sh])) $s .= 'box-shadow:' . $shadows[$sh] . ';';
        foreach (['minHeight' => 'min-height', 'maxHeight' => 'max-height'] as $k => $css) {
            $v = trim((string) ($p[$k] ?? ''));
            if ($v !== '') { $v = self::cssLen(preg_match('/^\d+(\.\d+)?$/', $v) ? $v . 'px' : $v); if ($v !== '') $s .= $css . ':' . $v . ';'; }
        }
        return $s;
    }

    /**
     * Cell Grid: positions swallowed by a merge (keyed "r:c"). Mirrors
     * lgCovered() in registry.js exactly — a covered position's own spans are
     * ignored, scanning row-major.
     */
    private static function lgCovered(int $R, int $C, array $cells): array
    {
        $cov = [];
        for ($r = 0; $r < $R; $r++) {
            for ($c = 0; $c < $C; $c++) {
                if (isset($cov[$r . ':' . $c])) continue;
                $cfg = (array) ($cells[$r . ':' . $c] ?? []);
                $rs = max(1, (int) ($cfg['rs'] ?? 1));
                $cs = max(1, (int) ($cfg['cs'] ?? 1));
                for ($i = $r; $i < min($R, $r + $rs); $i++) {
                    for ($j = $c; $j < min($C, $c + $cs); $j++) {
                        if ($i !== $r || $j !== $c) $cov[$i . ':' . $j] = true;
                    }
                }
            }
        }
        return $cov;
    }

    /** Cell Grid column tracks from the csv prop (invalid token → flexible). Mirrors lgTracks(). */
    private static function lgTracks(string $colWidths, int $C): string
    {
        $toks = array_map('trim', explode(',', $colWidths));
        $out = [];
        for ($i = 0; $i < $C; $i++) {
            $t = $toks[$i] ?? '';
            $out[] = preg_match('/^([0-9.]+(fr|px|%|rem|em)|auto|min-content|max-content)$/', $t) ? $t : 'minmax(0,1fr)';
        }
        return implode(' ', $out);
    }

    /** Custom row-track heights (mirror of lgRows() in registry.js). */
    private static function lgRows(string $rowHeights, int $R): string
    {
        $toks = array_map('trim', explode(',', $rowHeights));
        $out = [];
        for ($i = 0; $i < $R; $i++) {
            $t = $toks[$i] ?? '';
            $out[] = preg_match('/^([0-9.]+(fr|px|%|rem|em|vh)|auto|min-content|max-content)$/', $t) ? $t : 'auto';
        }
        return implode(' ', $out);
    }

    /** Per-cell responsive classes (hide / full-width per device). Mirrors lgCellCls() in registry.js. */
    private static function lgCellCls(array $cfg): string
    {
        $h = ['left' => ' lg-h-left', 'center' => ' lg-h-center', 'right' => ' lg-h-right'][$cfg['align'] ?? ''] ?? '';
        $v = ['top' => ' lg-v-top', 'middle' => ' lg-v-middle', 'bottom' => ' lg-v-bottom'][$cfg['valign'] ?? ''] ?? '';
        return (!empty($cfg['hideM']) ? ' lg-hide-m' : '') . (!empty($cfg['hideT']) ? ' lg-hide-t' : '') . (!empty($cfg['hideD']) ? ' lg-hide-d' : '')
            . (!empty($cfg['fullT']) ? ' lg-full-t' : '') . (!empty($cfg['fullM']) ? ' lg-full-m' : '') . $h . $v;
    }

    /** Block javascript:/vbscript:/data: hrefs (XSS); everything else passes through. */
    private static function safeHref(string $u): string
    {
        return preg_match('~^\s*(javascript|vbscript|data)\s*:~i', $u) ? '#' : $u;
    }

    /** Build the real video embed for a video-embed component (YouTube/Vimeo/MP4). */
    private static function videoEmbedHtml(array $p): string
    {
        $url = trim((string) ($p['url'] ?? ''));
        $auto = filter_var($p['autoplay'] ?? false, FILTER_VALIDATE_BOOLEAN);
        $mute = filter_var($p['muted'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
        $loop = filter_var($p['loop'] ?? false, FILTER_VALIDATE_BOOLEAN);
        $controls = filter_var($p['controls'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
        if ($url === '') {
            return '<div class="ppd-c-video-ph"><span class="ppd-c-video-play">' . self::ic('bi-play-fill') . '</span><span class="ppd-c-video-lbl">No video</span></div>';
        }
        // YouTube
        if (preg_match('~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{6,})~', $url, $m)) {
            $id = $m[1];
            $q = ['rel' => '0', 'modestbranding' => '1'];
            if ($auto) { $q['autoplay'] = '1'; $q['mute'] = '1'; }
            if ($loop) { $q['loop'] = '1'; $q['playlist'] = $id; }
            $src = 'https://www.youtube-nocookie.com/embed/' . rawurlencode($id) . '?' . http_build_query($q);
            return '<iframe src="' . self::e($src) . '" title="Video" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>';
        }
        // Vimeo
        if (preg_match('~vimeo\.com/(?:video/)?(\d+)~', $url, $m)) {
            $q = [];
            if ($auto) { $q['autoplay'] = '1'; $q['muted'] = '1'; }
            if ($loop) $q['loop'] = '1';
            $src = 'https://player.vimeo.com/video/' . rawurlencode($m[1]) . ($q ? '?' . http_build_query($q) : '');
            return '<iframe src="' . self::e($src) . '" title="Video" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>';
        }
        // Direct video file
        if (preg_match('~\.(mp4|webm|ogg)(\?|#|$)~i', $url)) {
            $poster = trim((string) ($p['poster'] ?? ''));
            $attrs = ($controls ? ' controls' : '') . ($auto ? ' autoplay' : '') . (($mute || $auto) ? ' muted' : '') . ($loop ? ' loop' : '') . ' playsinline preload="metadata"';
            if ($poster !== '' && !preg_match('~^\s*(javascript|vbscript|data)\s*:~i', $poster)) $attrs .= ' poster="' . self::e($poster) . '"';
            return '<video src="' . self::e($url) . '"' . $attrs . '></video>';
        }
        // Generic iframe (only http/https)
        if (preg_match('~^https?://~i', $url)) {
            return '<iframe src="' . self::e($url) . '" title="Video" loading="lazy" frameborder="0" allowfullscreen></iframe>';
        }
        return '<div class="ppd-c-video-ph"><span class="ppd-c-video-lbl">Unsupported video URL</span></div>';
    }

    /** Count-up animation for Stats components (emitted once; idempotent). */
    private static function statsCountUpScript(): string
    {
        return '<script>(function(){if(window.__ppStatsInit)return;window.__ppStatsInit=1;'
            . 'function run(){var els=document.querySelectorAll(".ppd-c-stat-val[data-to]:not([data-done])");'
            . 'if(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches){els.forEach(function(e){e.setAttribute("data-done","1");});return;}'
            . 'var io=new IntersectionObserver(function(es){es.forEach(function(en){if(!en.isIntersecting)return;var el=en.target;io.unobserve(el);el.setAttribute("data-done","1");'
            . 'var raw=el.getAttribute("data-to")||"";var m=raw.match(/-?[0-9]*\\.?[0-9]+/);if(!m){return;}var to=parseFloat(m[0]);var dec=(m[0].split(".")[1]||"").length;var pre=raw.slice(0,m.index),post=raw.slice(m.index+m[0].length);'
            . 'var st=null,dur=1200;function tick(ts){st=st||ts;var k=Math.min(1,(ts-st)/dur);var e=1-Math.pow(1-k,3);var val=(to*e).toFixed(dec);el.textContent=pre+val+post;if(k<1)requestAnimationFrame(tick);else el.textContent=raw;}requestAnimationFrame(tick);});},{threshold:0.4});'
            . 'els.forEach(function(e){io.observe(e);});}'
            . 'if(document.readyState!=="loading")run();else document.addEventListener("DOMContentLoaded",run);})();</script>';
    }

    /** Live countdown ticker for Countdown components (emitted once; idempotent). */
    private static function countdownScript(): string
    {
        return '<script>(function(){if(window.__ppCdInit)return;window.__ppCdInit=1;'
            . 'function pad(v){return (v<10?"0":"")+v;}'
            . 'function upd(){var ls=document.querySelectorAll(".ppd-c-cd[data-to]");for(var i=0;i<ls.length;i++){(function(el){'
            . 'var t=Date.parse(el.getAttribute("data-to"));if(isNaN(t)){return;}'
            . 'var d=Math.floor((t-Date.now())/1000);'
            . 'if(d<=0){var ex=(el.getAttribute("data-expired")||"").replace(/[<>&]/g,"");el.innerHTML="<div class=\'ppd-c-cd-done\'>"+ex+"</div>";return;}'
            . 'var days=Math.floor(d/86400),h=Math.floor((d%86400)/3600),m=Math.floor((d%3600)/60),s=d%60;'
            . 'function set(u,v){var e=el.querySelector("[data-u=\'"+u+"\']");if(e){e.textContent=pad(v);}}'
            . 'set("days",days);set("hours",h);set("mins",m);set("secs",s);'
            . '})(ls[i]);}}'
            . 'upd();setInterval(upd,1000);})();</script>';
    }

    /** Scroll-reveal ("Animate in") observer (emitted once; idempotent). Public so
     *  BuildWriter::wrapComponent can attach it when a component opts in. */
    public static function revealScript(): string
    {
        return '<script>(function(){if(window.__ppRevealInit)return;window.__ppRevealInit=1;'
            . 'function run(){var els=[].slice.call(document.querySelectorAll(".ppd-c-reveal"));if(!els.length)return;'
            . 'if(!("IntersectionObserver" in window)||(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches)){els.forEach(function(e){e.classList.add("is-in");});return;}'
            . 'els.forEach(function(e){e.classList.add("is-armed");});'
            . 'var io=new IntersectionObserver(function(es){es.forEach(function(en){if(en.isIntersecting){en.target.classList.add("is-in");io.unobserve(en.target);}});},{threshold:0.12,rootMargin:"0px 0px -8% 0px"});'
            . 'els.forEach(function(e){io.observe(e);});}'
            . 'if(document.readyState!=="loading")run();else document.addEventListener("DOMContentLoaded",run);})();</script>';
    }

    /** Back-to-top show/scroll behaviour (emitted once; idempotent). */
    private static function backToTopScript(): string
    {
        return '<script>(function(){if(window.__ppTopInit)return;window.__ppTopInit=1;'
            . 'var bs=[].slice.call(document.querySelectorAll(".ppd-c-totop"));if(!bs.length)return;'
            . 'bs.forEach(function(b){b.addEventListener("click",function(){window.scrollTo({top:0,behavior:"smooth"});});});'
            . 'function on(){var y=window.pageYOffset||document.documentElement.scrollTop||0;bs.forEach(function(b){var a=parseInt(b.getAttribute("data-after"),10)||400;b.classList.toggle("is-visible",y>a);});}'
            . 'window.addEventListener("scroll",on,{passive:true});on();})();</script>';
    }

    /** Cookie-consent persistence via localStorage (emitted once; idempotent). */
    private static function cookieScript(): string
    {
        return '<script>(function(){if(window.__ppCkInit)return;window.__ppCkInit=1;'
            . '[].slice.call(document.querySelectorAll(".ppd-c-cookie[id]")).forEach(function(el){var k="ppck:"+el.id;'
            . 'try{if(localStorage.getItem(k)){el.parentNode&&el.parentNode.removeChild(el);return;}}catch(e){}'
            . 'requestAnimationFrame(function(){el.classList.add("is-live");});'
            . '[].slice.call(el.querySelectorAll("[data-ck]")).forEach(function(btn){btn.addEventListener("click",function(){try{localStorage.setItem(k,btn.getAttribute("data-ck"));}catch(e){}el.classList.remove("is-live");el.classList.add("is-hidden");setTimeout(function(){el.parentNode&&el.parentNode.removeChild(el);},260);});});});})();</script>';
    }

    /** Image wrapper box style (sanitized). Mirrors imgBoxCss() in registry.js. */
    private static function imageBoxCss(array $p): string
    {
        $ci = static fn ($v, int $lo, int $hi, int $d) => is_numeric($v) ? max($lo, min($hi, (int) $v)) : $d;
        $w = $ci($p['width'] ?? null, 5, 100, 100);
        $s = 'width:' . $w . '%;max-width:100%;';
        $al = (string) ($p['align'] ?? 'center');
        $s .= $al === 'left' ? 'margin-right:auto;margin-left:0;' : ($al === 'right' ? 'margin-left:auto;margin-right:0;' : 'margin-left:auto;margin-right:auto;');
        $bg = self::cssColor($p['bg'] ?? ''); if ($bg !== '') $s .= 'background:' . $bg . ';';
        $pad = $ci($p['pad'] ?? null, 0, 120, 0); if ($pad) $s .= 'padding:' . $pad . 'px;';
        $rad = $ci($p['radius'] ?? null, 0, 400, 0); if ($rad) $s .= 'border-radius:' . $rad . 'px;';
        if (!empty($p['border'])) {
            $bc = self::cssColor($p['borderColor'] ?? '') ?: 'var(--ppd-border,#e2e8f0)';
            $s .= 'border:' . $ci($p['borderWidth'] ?? null, 1, 40, 1) . 'px solid ' . $bc . ';';
        }
        $sh = ['sm' => '0 2px 10px rgba(2,6,23,.10)', 'md' => '0 10px 26px rgba(2,6,23,.14)', 'lg' => '0 20px 46px rgba(2,6,23,.22)'][$p['shadow'] ?? ''] ?? '';
        if ($sh !== '') $s .= 'box-shadow:' . $sh . ';';
        return $s;
    }

    /** Image <img> style (sanitized). Mirrors imgImgCss() in registry.js. */
    private static function imageImgCss(array $p): string
    {
        $ci = static fn ($v, int $lo, int $hi, int $d) => is_numeric($v) ? max($lo, min($hi, (int) $v)) : $d;
        $s = '';
        $r = (string) ($p['ratio'] ?? 'auto');
        if ($r !== '' && $r !== 'auto') $s .= 'aspect-ratio:' . self::css($r) . ';height:auto;';
        $fit = in_array($p['fit'] ?? '', ['cover', 'contain', 'fill', 'scale-down', 'none'], true) ? (string) $p['fit'] : 'contain';
        $s .= 'object-fit:' . $fit . ';';
        $pos = (string) ($p['position'] ?? 'center');
        if ($pos !== 'center' && in_array($pos, ['top', 'bottom', 'left', 'right'], true)) $s .= 'object-position:' . $pos . ';';
        $op = $ci($p['opacity'] ?? null, 0, 100, 100); if ($op < 100) $s .= 'opacity:' . ($op / 100) . ';';
        $filt = ''; if (!empty($p['grayscale'])) $filt .= 'grayscale(1) ';
        $bl = $ci($p['blur'] ?? null, 0, 30, 0); if ($bl) $filt .= 'blur(' . $bl . 'px) ';
        if ($filt !== '') $s .= 'filter:' . trim($filt) . ';';
        return $s;
    }

    /** Cell Grid per-cell inline style (sanitized). Mirrors lgCellCss() in registry.js. */
    private static function lgCellStyle(array $cfg): string
    {
        $s = '';
        if (in_array($cfg['align'] ?? '', ['left', 'center', 'right'], true)) $s .= 'text-align:' . $cfg['align'] . ';';
        // Vertical align is a class (lg-v-*) targeting the zone's align-content —
        // justify-content on the cell is a no-op (the zone fills the cell height).
        $bg = self::cssColor($cfg['bg'] ?? '');    if ($bg !== '') $s .= 'background:' . $bg . ';';
        $co = self::cssColor($cfg['color'] ?? ''); if ($co !== '') $s .= 'color:' . $co . ';';
        $pd = self::cssLen((string) ($cfg['pad'] ?? ''));    if ($pd !== '') $s .= 'padding:' . $pd . ';';
        $mg = self::cssLen((string) ($cfg['margin'] ?? '')); if ($mg !== '') $s .= 'margin:' . $mg . ';';
        $bd = self::css((string) ($cfg['border'] ?? '')); if ($bd !== '') $s .= 'border:' . $bd . ';';
        $mh = self::cssLen((string) ($cfg['minH'] ?? '')); if ($mh !== '') $s .= 'min-height:' . $mh . ';';
        $h  = self::cssLen((string) ($cfg['h'] ?? ''));    if ($h !== '')  $s .= 'height:' . $h . ';';
        $xh = self::cssLen((string) ($cfg['maxH'] ?? ''));  if ($xh !== '') $s .= 'max-height:' . $xh . ';overflow:auto;';
        return $s;
    }

    /** Menu Group border + radius + shadow inline style. Mirrors mgBorderStyle() in registry.js. */
    private static function mgBorderStyle(array $p): string
    {
        static $shadows = [
            'none' => 'none', 'soft' => '0 4px 16px rgba(2,6,23,.07)',
            'medium' => '0 10px 26px rgba(2,6,23,.13)', 'strong' => '0 18px 44px rgba(2,6,23,.20)',
        ];
        $s  = '';
        $bs = (string) ($p['borderStyle'] ?? '');
        if ($bs === 'none') {
            $s .= 'border:none;';
        } elseif (in_array($bs, ['solid', 'dashed', 'dotted'], true)) {
            $w = self::cssLen((string) ($p['borderWidth'] ?? '')) ?: '1px';
            $c = self::cssColor($p['borderColor'] ?? '') ?: '#e2e8f0';
            $s .= 'border:' . $w . ' ' . $bs . ' ' . $c . ';';
        } else {
            $w = self::cssLen((string) ($p['borderWidth'] ?? '')); if ($w !== '') $s .= 'border-width:' . $w . ';';
            $c = self::cssColor($p['borderColor'] ?? '');          if ($c !== '') $s .= 'border-color:' . $c . ';';
        }
        $r = trim((string) ($p['radius'] ?? ''));
        if ($r !== '') { $r = self::cssLen(preg_match('/^\d+(\.\d+)?$/', $r) ? $r . 'px' : $r); if ($r !== '') $s .= 'border-radius:' . $r . ';'; }
        $sh = (string) ($p['shadow'] ?? '');
        if ($sh !== '' && isset($shadows[$sh])) $s .= 'box-shadow:' . $shadows[$sh] . ';';
        return $s;
    }

    /** Menu Group heading inline style. Mirrors mgHeadStyle() in registry.js. */
    private static function mgHeadStyle(array $p): string
    {
        $s = '';
        $c = self::cssColor($p['headingColor'] ?? ''); if ($c !== '') $s .= 'color:' . $c . ';';
        $f = self::css((string) ($p['headingFont'] ?? '')); if ($f !== '') $s .= 'font-family:' . $f . ';';
        $z = trim((string) ($p['headingSize'] ?? ''));
        if ($z !== '') { $z = self::cssLen(preg_match('/^\d+(\.\d+)?$/', $z) ? $z . 'px' : $z); if ($z !== '') $s .= 'font-size:' . $z . ';'; }
        return $s;
    }

    private static function stripPhpTags(string $code): string
    {
        return trim((string) preg_replace('/<\?php|<\?=|<\?|\?>/', '', $code));
    }

    private static function e(string $v): string
    {
        return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
    }

    /**
     * Every distinct authored component CONTENT string emitted through t()/tExpr()
     * this build — drained by BuildWriter to populate the Translations editor's
     * "Content" catalogue so a user can discover and translate them.
     *
     * @var array<string,bool>
     */
    public static array $content = [];

    /** Drain + reset the collected content strings (BuildWriter calls this). */
    public static function takeContent(): array
    {
        $out = array_keys(self::$content);
        self::$content = [];
        return $out;
    }

    /**
     * Translatable component content, HTML-escaped, for echoing into element text
     * or an attribute value. Returns an emitted `<?= pp_ct_e('src') ?>` tag so the
     * generated page resolves the active language at request time (English when
     * English is selected, Nepali when Nepali is). Empty → ''. The authored text
     * is the fallback, so untranslated strings render verbatim.
     */
    private static function t(string $text, string $default = ''): string
    {
        $s = trim($text);
        if ($s === '') $s = trim($default);
        if ($s === '') return '';
        self::$content[$s] = true;
        return '<?= pp_ct_e(' . var_export($s, true) . ') ?>';
    }

    /**
     * Like t() but returns a PHP EXPRESSION yielding the RAW (unescaped) translated
     * string — for use inside emitted PHP (json_encode payloads, concatenation).
     * Returns the literal "''" when empty.
     */
    private static function tExpr(string $text, string $default = ''): string
    {
        $s = trim($text);
        if ($s === '') $s = trim($default);
        if ($s === '') return "''";
        self::$content[$s] = true;
        return 'pp_ct(' . var_export($s, true) . ')';
    }

    /**
     * A data-bound tile's title (KPI stat, menu card) that still shows the raw
     * humanized table name is UI chrome → emit a runtime pp_table_caption() tag
     * so it follows the Misc caption + active language (मुद्दाहरू…), matching the
     * menu, breadcrumbs and list headings. A heading the user actually customised
     * (differs from the default humanized table name) stays literal.
     */
    private static function tableCaptionOrText(array $p, string $text): string
    {
        $t = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
        if ($t === '') return self::e($text);
        $humanized = ucwords(str_replace(['-', '_'], ' ', $t));
        if (trim($text) !== '' && strcasecmp(trim($text), $humanized) !== 0) {
            return self::t($text);   // custom title — translatable component content
        }
        $fallback = $text !== '' ? $text : $humanized;
        return "<?= htmlspecialchars(function_exists('pp_table_caption') ? pp_table_caption('" . $t . "', "
            . var_export($fallback, true) . ") : " . var_export($fallback, true) . ") ?>";
    }

    /**
     * A section/card heading that matches a known auto-scaffolded default is UI
     * chrome → emit a runtime pp_t() tag so it follows the app language. Any other
     * (author-typed) heading is escaped literally as content. Mirrors the inline
     * default-label pattern used elsewhere in this renderer.
     */
    private static function chromeHeading(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 . "')) ?>";
        }
        // Author-typed heading → translatable component content (pp_ct).
        return self::t($text);
    }

    /**
     * Inline-PHP live aggregate for a KPI binding (kpi-stat, kpi-row tiles).
     * Identifiers whitelisted at BUILD time; the WHERE is designer-authored
     * (same trust as Events sqlWhere), hardened. '' when unbound.
     */
    /** SQL-safe identifier: strip everything but [A-Za-z0-9_]. */
    private static function ident(mixed $v): string
    {
        return preg_replace('/[^A-Za-z0-9_]/', '', (string) $v) ?? '';
    }

    /**
     * Recent-Activity / Notifications feed: recent rows of a table, title +
     * optional date. Emits a runtime PHP block that queries at page load.
     */
    private static function feedWidgetPhp(array $p, string $defHeading, string $rowIcon): string
    {
        $t  = self::ident($p['table'] ?? '');
        $tc = self::ident($p['titleColumn'] ?? '');
        $dc = self::ident($p['dateColumn'] ?? '');
        $n  = max(1, min(12, (int) ($p['count'] ?? 4)));
        $h  = trim((string) ($p['heading'] ?? '')); if ($h === '') $h = $defHeading;
        $ic = self::ic($rowIcon);
        $head = '<div class="ppd-widget"><div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . self::e($h) . '</strong></div>';
        if ($t === '') {
            return $head . '<div class="ppd-feed-row">' . $ic . '<div><span class="k">Bind this widget to a table</span></div></div></div>';
        }
        $order  = $dc !== '' ? " ORDER BY `$dc` DESC" : '';
        $titleE = $tc !== '' ? "(string)(\$__r['$tc'] ?? '')" : "'Record'";
        $dateE  = $dc !== '' ? "(string)(\$__r['$dc'] ?? '')" : "''";
        $php = "<?php\n"
            . "\$__fw = \\PpPhp\\Core\\DB::FetchAll(\"SELECT * FROM `$t`$order LIMIT $n\") ?: [];\n"
            . "if (!\$__fw) echo '<div class=\"ppd-feed-row\">$ic<div><span class=\"k\">No records yet</span></div></div>';\n"
            . "foreach (\$__fw as \$__r) { \$__ti = $titleE; if (\$__ti === '') \$__ti = 'Record'; \$__da = $dateE;\n"
            . "  echo '<div class=\"ppd-feed-row\">$ic<div><span class=\"k\">' . htmlspecialchars(\$__ti) . '</span>' . (\$__da !== '' ? '<small>' . htmlspecialchars(\$__da) . '</small>' : '') . '</div></div>'; }\n"
            . "?>";
        return $head . $php . '</div>';
    }

    /** Mini-table: first N rows of chosen columns (blank = first 4). */
    /** Guarded number-formatter emitted once per page (decimals / abbreviate / grouping). */
    private static function ppKpiFmtHelper(): string
    {
        return '<?php if(!function_exists("pp_kpi_fmt")){function pp_kpi_fmt($n,$dec,$abbr,$thou){$a=abs($n);'
            . 'if($abbr){if($a>=1e12)return number_format($n/1e12,$dec)."T";if($a>=1e9)return number_format($n/1e9,$dec)."B";if($a>=1e6)return number_format($n/1e6,$dec)."M";if($a>=1e3)return number_format($n/1e3,$dec)."k";}'
            . 'return number_format($n,$dec,".",$thou?",":"");}} ?>';
    }

    /** Link/button style class shared by External Link, Download Link, etc. */
    private static function linkClass(string $style): string
    {
        return $style === 'button' ? 'ppd-linkbtn' : ($style === 'link' ? 'ppd-linktext' : 'ppd-linkchip');
    }

    /** Build the list-page href for a quick filter (field+value / search / clear). */
    private static function filterHref(string $base, string $field, string $value): string
    {
        if ($field !== '' && $value !== '') return $base . '?f%5B' . rawurlencode($field) . '%5D=' . rawurlencode($value);
        if ($value !== '') return $base . '?search=' . rawurlencode($value);
        return $base;   // "All" / clear
    }

    /** Inline PHP that echoes a live " (N)" count for a quick filter, or '' when uncountable. */
    private static function filterCountHtml(string $table, string $field, string $value): string
    {
        if ($field !== '' && $value !== '') {
            return '<?php try { echo \' <span class="pp-fchip-n">(\' . (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `' . $table . '` WHERE `' . $field . '` LIKE ?", [' . var_export('%' . $value . '%', true) . ']) . \')</span>\'; } catch (\Throwable $__e) {} ?>';
        }
        if ($field === '' && $value === '') {   // "All" → total
            return '<?php try { echo \' <span class="pp-fchip-n">(\' . (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(*) FROM `' . $table . '`") . \')</span>\'; } catch (\Throwable $__e) {} ?>';
        }
        return '';   // a bare search term: matches multiple columns, so no simple count
    }

    /** PHP boolean expression: is this quick filter the one currently applied? */
    private static function filterActiveExpr(string $field, string $value): string
    {
        if ($field !== '' && $value !== '') {
            return '(($_GET["f"][' . var_export($field, true) . '] ?? "") === ' . var_export($value, true) . ')';
        }
        if ($value !== '') {
            return '(($_GET["search"] ?? "") === ' . var_export($value, true) . ')';
        }
        return '(empty($_GET["f"]) && ($_GET["search"] ?? "") === "" && ($_GET["wtab"] ?? "") === "")';
    }

    private static function miniTablePhp(array $p): string
    {
        $t    = self::ident($p['table'] ?? '');
        $cols = array_values(array_filter(array_map([self::class, 'ident'], self::arr($p['columns'] ?? []))));
        $oc   = self::ident($p['orderColumn'] ?? '');
        $od   = strtoupper((string) ($p['orderDir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
        $n    = max(1, min(20, (int) ($p['rows'] ?? 4)));
        $h    = trim((string) ($p['heading'] ?? '')); if ($h === '') $h = 'Records';
        $head = '<div class="ppd-widget"><div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . self::e($h) . '</strong></div>';
        if ($t === '') {
            return $head . '<table class="ppd-minitable"><tbody><tr><td>Bind this widget to a table</td></tr></tbody></table></div>';
        }
        $sel    = $cols === [] ? '*' : implode(',', array_map(static fn ($c) => "`$c`", $cols));
        $order  = $oc !== '' ? " ORDER BY `$oc` $od" : '';
        $colsE  = $cols === [] ? "(\$__mt ? array_slice(array_keys(\$__mt[0]), 0, 4) : [])" : "['" . implode("','", $cols) . "']";
        // Optional WHERE — expanded via SqlVars at request time (static SELECT/ORDER/LIMIT baked).
        $where  = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
        $fetchArg = $where !== ''
            ? "\"SELECT $sel FROM `$t` WHERE \" . \\PpPhp\\Core\\SqlVars::expand(" . var_export($where, true) . ") . \"$order LIMIT $n\""
            : "\"SELECT $sel FROM `$t`$order LIMIT $n\"";
        $php = "<?php\n"
            . "\$__mt = \\PpPhp\\Core\\DB::FetchAll($fetchArg) ?: [];\n"
            . "\$__cols = $colsE;\n"
            . "echo '<table class=\"ppd-minitable\"><thead><tr>';\n"
            . "foreach (\$__cols as \$__c) echo '<th>' . htmlspecialchars(ucwords(str_replace('_', ' ', (string) \$__c))) . '</th>';\n"
            . "echo '</tr></thead><tbody>';\n"
            . "if (!\$__mt) echo '<tr><td colspan=\"'.max(1,count(\$__cols)).'\">No records yet</td></tr>';\n"
            . "foreach (\$__mt as \$__row) { echo '<tr>'; foreach (\$__cols as \$__c) echo '<td>' . htmlspecialchars((string)(\$__row[\$__c] ?? '')) . '</td>'; echo '</tr>'; }\n"
            . "echo '</tbody></table>';\n?>";
        return $head . $php . '</div>';
    }

    /**
     * Data-bound Post Grid: one card per row, mapping columns to the card fields.
     * Cards are built into $__pgHtml at request time (SqlVars WHERE supported); an
     * empty result shows a graceful "No posts yet" card. Each card can link to the
     * record's generated view page (linkView) or to a URL held in a column.
     */
    private static function postGridDbPhp(array $p, string $cols, string $head): string
    {
        $t          = self::ident($p['table'] ?? '');
        $titleCol   = self::ident($p['titleColumn'] ?? '');
        $excerptCol = self::ident($p['excerptColumn'] ?? '');
        $imageCol   = self::ident($p['imageColumn'] ?? '');
        $catCol     = self::ident($p['categoryColumn'] ?? '');
        $authorCol  = self::ident($p['authorColumn'] ?? '');
        $dateCol    = self::ident($p['dateColumn'] ?? '');
        $linkCol    = self::ident($p['linkColumn'] ?? '');
        $idCol      = self::ident($p['idColumn'] ?? '') ?: 'id';
        $linkView   = filter_var($p['linkView'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
        $oc         = self::ident($p['orderColumn'] ?? '');
        $od         = strtoupper((string) ($p['orderDir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
        $n          = max(1, min(30, (int) ($p['limit'] ?? 6)));
        $where      = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
        $order      = $oc !== '' ? " ORDER BY `$oc` $od" : '';
        $sqlExpr = $where !== ''
            ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . "' . $order . ' LIMIT ' . $n . '"'
            : '"SELECT * FROM `' . $t . '`' . $order . ' LIMIT ' . $n . '"';
        $col = fn (string $c): string => $c !== '' ? "(string)(\$__r['$c'] ?? '')" : "''";
        $hrefPhp = $linkCol !== ''
            ? "(string)(\$__r['$linkCol'] ?? '')"
            : ($linkView ? "(isset(\$__r['$idCol']) ? APP_BASE.'/generated/$t/view.php?id='.rawurlencode((string)\$__r['$idCol']) : '')" : "''");
        $icImg   = self::ic('bi-image');
        $icArrow = self::ic('bi-arrow-right');
        $php = "<?php \$__pgHtml=''; try{\$__pg=\\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: [];}catch(\\Throwable \$__e){\$__pg=[];}\n"
            . "foreach(\$__pg as \$__r){\n"
            . "  \$__img=" . $col($imageCol) . "; \$__ti=" . $col($titleCol) . "; \$__ex=" . $col($excerptCol) . ";\n"
            . "  \$__cat=" . $col($catCol) . "; \$__au=" . $col($authorCol) . "; \$__dt=" . $col($dateCol) . ";\n"
            . "  \$__media = \$__img!=='' ? '<img src=\"'.htmlspecialchars(\$__img,ENT_QUOTES).'\" alt=\"\" loading=\"lazy\">' : '<div class=\"ppd-c-post-ph\">$icImg</div>';\n"
            . "  \$__meta = trim(implode(' · ', array_filter([\$__au, \$__dt])));\n"
            . "  \$__body = '<div class=\"ppd-c-post-body\">'.(\$__cat!==''?'<span class=\"ppd-c-post-cat\">'.htmlspecialchars(\$__cat).'</span>':'').'<h3>'.htmlspecialchars(\$__ti).'</h3>'.(\$__ex!==''?'<p>'.htmlspecialchars(\$__ex).'</p>':'').(\$__meta!==''?'<div class=\"ppd-c-post-meta\">'.htmlspecialchars(\$__meta).'</div>':'').'<span class=\"ppd-c-post-more\">Read more$icArrow</span></div>';\n"
            . "  \$__card = '<div class=\"ppd-c-post-media\">'.\$__media.'</div>'.\$__body;\n"
            . "  \$__href = " . $hrefPhp . ";\n"
            . "  \$__pgHtml .= (\$__href!=='' ? '<a class=\"ppd-c-post\" href=\"'.htmlspecialchars(\$__href,ENT_QUOTES).'\">'.\$__card.'</a>' : '<article class=\"ppd-c-post\">'.\$__card.'</article>');\n"
            . "}\n"
            . "if(\$__pgHtml===''){ \$__pgHtml='<article class=\"ppd-c-post\"><div class=\"ppd-c-post-body\"><h3>No posts yet</h3></div></article>'; }\n?>";
        return $php . '<section class="ppd-c-posts"><div class="ppd-c-post-wrap">' . $head . '<div class="ppd-c-post-grid cols-' . $cols . '"><?php echo $__pgHtml; ?></div></div></section>';
    }

    /** Current-month calendar; if bound, marks days that have records. */
    private static function calendarPhp(array $p, bool $full, string $cid = ''): string
    {
        $t  = self::ident($p['table'] ?? '');
        $dc = self::ident($p['dateColumn'] ?? '');
        $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
        // BS/AD switch: 'english' AD only, 'nepali' BS only, 'both' → a toggle.
        $calType = in_array($p['calendarType'] ?? 'english', ['english', 'nepali', 'both'], true) ? (string) ($p['calendarType'] ?? 'english') : 'english';
        $calDefault = in_array($p['calendarDefault'] ?? '', ['english', 'nepali'], true)
            ? (string) $p['calendarDefault']
            : ($calType === 'nepali' ? 'nepali' : 'english');
        $titleCol = self::ident($p['titleColumn'] ?? '');   // optional — powers the day tooltip

        $weekStart      = ($p['weekStart'] ?? 'sunday') === 'monday' ? 1 : 0;
        $showHeader     = filter_var($p['showHeader'] ?? $full, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) === true;
        $showWeekdays   = filter_var($p['showWeekdays'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
        $shadeWeekends  = filter_var($p['shadeWeekends'] ?? false, FILTER_VALIDATE_BOOLEAN);
        $highlightToday = filter_var($p['highlightToday'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
        $markStyle      = (string) ($p['markStyle'] ?? 'fill');
        $size           = (string) ($p['size'] ?? 'md');
        $shape          = (string) ($p['cellShape'] ?? 'rounded');
        $accent         = self::cssColor($p['accent'] ?? '');
        $eventColor     = self::cssColor($p['eventColor'] ?? '');
        $headerText     = trim((string) ($p['headerText'] ?? ''));
        $monthProp      = (int) ($p['month'] ?? 0);
        $yearProp       = (int) ($p['year'] ?? 0);

        $cls = 'ppd-mcal-wrap';
        if ($size === 'sm') $cls .= ' cal-sm'; elseif ($size === 'lg') $cls .= ' cal-lg';
        if ($shape === 'square') $cls .= ' cal-square'; elseif ($shape === 'circle') $cls .= ' cal-circle';
        if (in_array($markStyle, ['dot', 'ring', 'badge'], true)) $cls .= ' mk-' . $markStyle;
        // Weekend days (0=Sun … 6=Sat) — configurable per country. Unset = Sat & Sun.
        $weekendDays = isset($p['weekendDays']) && is_array($p['weekendDays'])
            ? array_values(array_unique(array_filter(array_map('intval', $p['weekendDays']), fn($d) => $d >= 0 && $d <= 6)))
            : [0, 6];
        $weekendColor = self::cssColor($p['weekendColor'] ?? '');

        $sv = $accent !== '' ? '--mcal-accent:' . $accent . ';' : '';
        if ($eventColor !== '') $sv .= '--mcal-event:' . $eventColor . ';';
        if ($shadeWeekends && $weekendColor !== '') $sv .= '--mcal-weekend:' . $weekendColor . ';';
        if (($mw = self::cssLen($p['maxWidth'] ?? '')) !== '') {
            $align = (string) ($p['align'] ?? 'left');
            $sv .= 'max-width:' . $mw . ';' . ($align === 'center' ? 'margin:0 auto;' : ($align === 'right' ? 'margin-left:auto;' : ''));
        }
        $styleAttr = $sv !== '' ? ' style="' . self::e($sv) . '"' : '';

        $wd = $weekStart === 1 ? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
        $wdHtml = '';
        if ($showWeekdays) { $wdHtml = '<div class="ppd-mcal-wd">'; foreach ($wd as $w) $wdHtml .= '<span>' . $w . '</span>'; $wdHtml .= '</div>'; }

        $moExpr = ($monthProp >= 1 && $monthProp <= 12) ? (string) $monthProp : "(int)date('n')";
        $yrExpr = ($yearProp > 0) ? (string) $yearProp : "(int)date('Y')";

        // Marks: rows-per-day for the shown month/year (runtime), WHERE via SqlVars.
        if ($t !== '' && $dc !== '') {
            $sqlBuild = '"SELECT DAY(`' . $dc . '`) d, COUNT(*) c FROM `' . $t . '` WHERE YEAR(`' . $dc . '`)=" . $__yr . " AND MONTH(`' . $dc . '`)=" . $__mo'
                . ($where !== '' ? ' . " AND (" . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . ")"' : '')
                . ' . " GROUP BY DAY(`' . $dc . '`)"';
            $marks = "\$__cm=[]; foreach (\\PpPhp\\Core\\DB::FetchAll($sqlBuild) ?: [] as \$__x) \$__cm[(int)\$__x['d']]=(int)\$__x['c'];";
        } else {
            $marks = "\$__cm=[];";
        }

        $badgeEcho = $markStyle === 'badge' ? " . (\$__has ? '<span class=\"ppd-mcal-badge\">' . (int)\$__cm[\$__i] . '</span>' : '')" : '';
        $wkndAdd   = $shadeWeekends ? " if (\$__wknd) \$__c2 .= ' is-weekend';" : '';
        $headEcho  = $showHeader
            ? "echo '<div class=\"ppd-mcal-head\">' . " . ($headerText !== '' ? 'htmlspecialchars(' . self::tExpr($headerText) . ')' : "date('F Y', mktime(0,0,0,\$__mo,1,\$__yr))") . " . '</div>';\n"
            : '';
        $wdEcho    = $showWeekdays ? "echo '" . str_replace("'", "\\'", $wdHtml) . "';\n" : '';

        $inner = "<?php\n"
            . "\$__mo = $moExpr; \$__yr = $yrExpr;\n"
            . "\$__first = (int)date('w', mktime(0,0,0,\$__mo,1,\$__yr)); \$__dim = (int)date('t', mktime(0,0,0,\$__mo,1,\$__yr));\n"
            . "\$__off = " . ($weekStart === 1 ? "((\$__first + 6) % 7)" : "\$__first") . ";\n"
            . "\$__td = " . ($highlightToday ? "((\$__mo === (int)date('n') && \$__yr === (int)date('Y')) ? (int)date('j') : -1)" : "-1") . ";\n"
            . $marks . "\n"
            . $headEcho
            . $wdEcho
            . "echo '<div class=\"ppd-mcal\">';\n"
            . "for (\$__b = 0; \$__b < \$__off; \$__b++) echo '<span class=\"ppd-mcal-day is-blank\"></span>';\n"
            . "for (\$__i = 1; \$__i <= \$__dim; \$__i++) { \$__dow = (\$__first + \$__i - 1) % 7; \$__wknd = in_array(\$__dow, [" . implode(',', $weekendDays) . "], true); \$__has = !empty(\$__cm[\$__i]);\n"
            . "  \$__c2 = 'ppd-mcal-day'; if (\$__i === \$__td) \$__c2 .= ' is-active'; if (\$__has) \$__c2 .= ' has-rec';$wkndAdd\n"
            . "  echo '<span class=\"' . \$__c2 . '\"' . (\$__has ? ' title=\"' . (int)\$__cm[\$__i] . ' record(s)\"' : '') . '>' . \$__i$badgeEcho . '</span>'; }\n"
            . "echo '</div>';\n?>";

        // ── Progressive enhancement: a data-bound calendar becomes an interactive
        // AD/BS (English/Nepali) month view via the pp-nepcal enhancer in app.js.
        // Day marks for a wide window (−6…+13 months) are baked as JSON so the
        // client can bucket them into whichever calendar + month the user browses;
        // the server-rendered $inner grid stays as the no-JS fallback. ──
        if ($t !== '' && $dc !== '') {
            // Rich per-day event items (id + title + labelled detail fields) power
            // the in-cell previews AND the click-to-open detail panel. detailColumns
            // is a CSV of "column:Label" pairs; keyColumn/linkTable drive the "open"
            // link to the record's view page.
            $keyCol = self::ident($p['keyColumn'] ?? 'id') ?: 'id';
            $linkTbl = self::ident($p['linkTable'] ?? '');
            $detailPairs = [];
            foreach (preg_split('/\s*,\s*/', (string) ($p['detailColumns'] ?? '')) as $pair) {
                $pair = trim($pair); if ($pair === '') continue;
                $bits = explode(':', $pair, 2);
                $col = self::ident($bits[0]); $lbl = trim($bits[1] ?? $bits[0]);
                if ($col !== '') $detailPairs[] = [$col, $lbl];
            }
            $sel = "`$keyCol` k, `" . ($titleCol !== '' ? $titleCol : $keyCol) . "` t";
            $fBuild = '';
            foreach ($detailPairs as $i => $pairCL) {
                $sel .= ", `" . $pairCL[0] . "` c$i";
                $fBuild .= '[' . var_export($pairCL[1], true) . ', (string) ($__r[' . var_export("c$i", true) . '] ?? "")], ';
            }
            $ncBuild = "<?php\n\$__ncMarks = [];\n"
                . "if (class_exists('\\\\PpPhp\\\\Core\\\\DB')) { try {\n"
                . "  \$__ncFrom = date('Y-m-d', strtotime('-6 months'));\n"
                . "  \$__ncTo = date('Y-m-d', strtotime('+13 months'));\n"
                . "  \$__ncW = \"DATE(`$dc`) BETWEEN ? AND ?\";\n"
                . ($where !== '' ? "  \$__ncW .= ' AND (' . \\PpPhp\\Core\\SqlVars::expand(" . var_export($where, true) . ") . ')';\n" : '')
                . "  \$__ncRls = class_exists('\\\\PpPhp\\\\Core\\\\Auth\\\\RowSecurity') ? \\PpPhp\\Core\\Auth\\RowSecurity::andSql('$t') : '';\n"
                . "  foreach (\\PpPhp\\Core\\DB::FetchAll(\"SELECT DATE(`$dc`) d, $sel FROM `$t` WHERE \" . \$__ncW . \$__ncRls . \" ORDER BY `$dc` LIMIT 3000\", [\$__ncFrom, \$__ncTo]) ?: [] as \$__r) {\n"
                . "    \$__k = (string) \$__r['d'];\n"
                . "    if (!isset(\$__ncMarks[\$__k])) \$__ncMarks[\$__k] = ['c' => 0, 'items' => []];\n"
                . "    \$__ncMarks[\$__k]['c']++;\n"
                . "    if (count(\$__ncMarks[\$__k]['items']) < 30) \$__ncMarks[\$__k]['items'][] = ['id' => (string) (\$__r['k'] ?? ''), 't' => (string) (\$__r['t'] ?? ''), 'f' => [$fBuild]];\n"
                . "  }\n"
                . "} catch (\\Throwable \$__e) {} }\n?>";
            $ncAttrs = ' data-nc-id="' . self::e($cid) . '"'
                . ' data-nc-type="' . $calType . '" data-nc-default="' . $calDefault . '"'
                . ' data-nc-accent="' . self::e($accent) . '" data-nc-weekstart="' . $weekStart . '"'
                . ' data-nc-today="' . ($highlightToday ? '1' : '0') . '"'
                . ' data-nc-head="' . ($showHeader ? '1' : '0') . '"'
                . ' data-nc-link="' . self::e($linkTbl) . '"'
                . ' data-nc-shade="' . ($shadeWeekends ? '1' : '0') . '"'
                . ' data-nc-weekend="' . self::e(implode(',', $weekendDays)) . '"'
                . ' data-nc-weekend-color="' . self::e($weekendColor) . '"'
                . ' data-nc-title="' . self::t(trim((string) ($p['headerText'] ?? $p['heading'] ?? ''))) . '"';
            $ncData = '<script type="application/json" class="pp-nc-data"><?= json_encode($__ncMarks, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>';
            $html = $ncBuild . '<div class="' . $cls . ' pp-nepcal"' . $ncAttrs . $styleAttr . '>' . $ncData
                . '<div class="pp-nc-mount">' . $inner . '</div>'
                . '<div class="pp-nc-panel" hidden></div></div>';
        } else {
            $html = '<div class="' . $cls . '"' . $styleAttr . '>' . $inner . '</div>';
        }
        if ($full) return $html;

        $h = trim((string) ($p['heading'] ?? '')); if ($h === '') $h = 'Calendar';
        return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' . self::partAttr($p, 'heading') . '>' . self::e($h) . '</strong></div>' . $html . '</div>';
    }

    /** Kanban: records grouped into columns by a status/group column. */
    /** One static Kanban card (manual mode). */
    private static function kbCard(string $title, string $sub = '', string $badge = '', string $date = ''): string
    {
        $f = ($badge !== '' ? '<span class="ppd-kanban-badge">' . self::e($badge) . '</span>' : '')
           . ($date !== '' ? '<span class="ppd-kanban-date">' . self::e($date) . '</span>' : '');
        return '<div class="ppd-kanban-card"><div class="ppd-kanban-ct">' . self::e($title) . '</div>'
            . ($sub !== '' ? '<div class="ppd-kanban-cs">' . self::e($sub) . '</div>' : '')
            . ($f !== '' ? '<div class="ppd-kanban-cf">' . $f . '</div>' : '') . '</div>';
    }

    private static function kanbanPhp(array $p): string
    {
        // Container class + CSS vars (layout / column & card style / accent / width).
        $cls = 'ppd-kanban';
        $layout = (string) ($p['layout'] ?? '');
        if ($layout === 'scroll') $cls .= ' kb-scroll'; elseif ($layout === 'wrap') $cls .= ' kb-wrap';
        $colS = (string) ($p['columnStyle'] ?? '');
        if ($colS === 'plain') $cls .= ' kb-col-plain'; elseif ($colS === 'bordered') $cls .= ' kb-col-bordered';
        $cardS = (string) ($p['cardStyle'] ?? '');
        if ($cardS === 'raised') $cls .= ' kb-card-raised'; elseif ($cardS === 'outline') $cls .= ' kb-card-outline';
        if (filter_var($p['compact'] ?? false, FILTER_VALIDATE_BOOLEAN)) $cls .= ' kb-compact';
        $cv = '';
        if (($v = self::cssColor($p['accent'] ?? '')) !== '') $cv .= '--kb-accent:' . $v . ';';
        if (($v = self::cssLen($p['columnWidth'] ?? '')) !== '') $cv .= '--kb-colw:' . $v . ';';
        $openAttr = $cv !== '' ? ' style="' . self::e($cv) . '"' : '';
        $open = '<div class="' . $cls . '"' . $openAttr . '>';
        $countBadge = filter_var($p['countBadge'] ?? false, FILTER_VALIDATE_BOOLEAN);
        $dnd = filter_var($p['dragDrop'] ?? false, FILTER_VALIDATE_BOOLEAN);

        $t  = self::ident($p['table'] ?? '');
        $gc = self::ident($p['groupColumn'] ?? '');
        $isDyn = ($p['source'] ?? '') === 'dynamic' || (($p['source'] ?? '') === '' && $t !== '');

        // ---- Manual board: columns + newline/pipe-delimited cards ----
        if (!$isDyn) {
            $board = self::arr($p['board'] ?? []);
            if (!$board) $board = [['title' => 'To do', 'cards' => ''], ['title' => 'In progress', 'cards' => ''], ['title' => 'Done', 'cards' => '']];
            $out = $open;
            foreach ($board as $col) {
                $col = (array) $col;
                $ca = self::cssColor($col['color'] ?? '');
                $colAttr = $ca !== '' ? ' style="--kb-caccent:' . self::e($ca) . '"' : '';
                $lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', (string) ($col['cards'] ?? '')) ?: []), static fn($l) => $l !== ''));
                $count = $countBadge ? '<span class="ppd-kanban-count">' . count($lines) . '</span>' : '';
                $cards = '';
                foreach ($lines as $l) {
                    $pt = array_map('trim', explode('|', $l));
                    $cards .= self::kbCard($pt[0] ?? '', $pt[1] ?? '', $pt[2] ?? '', $pt[3] ?? '');
                }
                if ($cards === '') $cards = '<div class="ppd-kanban-card is-empty">Empty</div>';
                $out .= '<div class="ppd-kanban-col"' . $colAttr . '><div class="ppd-kanban-h">' . self::e((string) ($col['title'] ?? '')) . $count . '</div>' . $cards . '</div>';
            }
            return $out . '</div>';
        }

        // ---- Dynamic: rich cards from a table ----
        $ucs = array_values(array_filter(array_map([self::class, 'ident'], self::arr($p['columns'] ?? []))));
        if ($t === '' || $gc === '') {
            $cols = $ucs !== [] ? $ucs : ['To do', 'In progress', 'Done'];
            $out = $open;
            foreach ($cols as $c) {
                $out .= '<div class="ppd-kanban-col"><div class="ppd-kanban-h">' . self::e($c) . '</div>'
                    . '<div class="ppd-kanban-card is-empty">Bind to a table + group column</div></div>';
            }
            return $out . '</div>';
        }
        $tc    = self::ident($p['titleColumn'] ?? '');
        $dc    = self::ident($p['descColumn'] ?? '');
        $bc    = self::ident($p['badgeColumn'] ?? '');
        $datec = self::ident($p['dateColumn'] ?? '');
        $ob    = self::ident($p['orderBy'] ?? '');
        $dir   = strtolower((string) ($p['orderDir'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
        $lim   = max(1, min(50, (int) ($p['limit'] ?? 8)));
        $where = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
        $empty = self::e((string) ($p['emptyText'] ?? '—'));
        $titleKey = $tc !== '' ? $tc : $gc;
        $order = $ob !== '' ? " ORDER BY `$ob` $dir" : '';
        $sqlExpr = $where !== ''
            ? '"SELECT * FROM `' . $t . '` WHERE " . \PpPhp\Core\SqlVars::expand(' . var_export($where, true) . ') . "' . $order . ' LIMIT 500"'
            : '"SELECT * FROM `' . $t . '`' . $order . ' LIMIT 500"';
        $colsE = $ucs === []
            ? "array_values(array_filter(array_unique(array_map(static fn(\$r) => (string)(\$r['$gc'] ?? ''), \$__kb)), static fn(\$v) => \$v !== ''))"
            : "['" . implode("','", $ucs) . "']";

        // Per-card pieces (runtime PHP expressions).
        $descE   = $dc !== '' ? "((\$__ds=trim((string)(\$__r['$dc']??''))) !== '' ? '<div class=\"ppd-kanban-cs\">'.htmlspecialchars(\$__ds).'</div>' : '')" : "''";
        $badgeE  = $bc !== '' ? "((\$__bv=trim((string)(\$__r['$bc']??''))) !== '' ? '<span class=\"ppd-kanban-badge\">'.htmlspecialchars(\$__bv).'</span>' : '')" : "''";
        $dateE   = $datec !== '' ? "((\$__dt=trim((string)(\$__r['$datec']??''))) !== '' ? '<span class=\"ppd-kanban-date\">'.htmlspecialchars(\$__dt).'</span>' : '')" : "''";
        $footerE = "((\$__ft = ($badgeE) . ($dateE)) !== '' ? '<div class=\"ppd-kanban-cf\">'.\$__ft.'</div>' : '')";
        $countE  = $countBadge ? "'<span class=\"ppd-kanban-count\">'.\$__n.'</span>'" : "''";

        // Drag & drop: cards carry their row id + are draggable; columns carry their
        // group value; the board carries table+group so kanban.js can persist a move
        // via api/save.php (same auth/perm/RLS path as inline edit). Cosmetic in the
        // Designer canvas — live only in the generated app.
        $cardOpenE = $dnd
            ? "'<div class=\"ppd-kanban-card\" draggable=\"true\" data-id=\"'.htmlspecialchars((string)(\$__r['id']??'')).'\">'"
            : "'<div class=\"ppd-kanban-card\">'";
        $cardE   = "$cardOpenE.'<div class=\"ppd-kanban-ct\">'.htmlspecialchars((string)(\$__r['$titleKey']??'')).'</div>'.($descE).($footerE).'</div>'";
        $colOpenE = $dnd
            ? "'<div class=\"ppd-kanban-col\" data-col=\"'.htmlspecialchars((string)\$__col).'\"><div class=\"ppd-kanban-h\">'"
            : "'<div class=\"ppd-kanban-col\"><div class=\"ppd-kanban-h\">'";
        $dynCls  = $cls . ($dnd ? ' kb-dnd' : '');
        $dynOpen = '<div class="' . $dynCls . '"' . $openAttr
            . ($dnd ? ' data-kanban-table="' . self::e($t) . '" data-kanban-group="' . self::e($gc) . '"' : '') . '>';

        $php = "<?php\n"
            . "\$__kb = \\PpPhp\\Core\\DB::FetchAll($sqlExpr) ?: [];\n"
            . "if (function_exists('pp_with_id')) pp_with_id(\$__kb, '$t');\n"
            . "\$__cols = $colsE; if (!\$__cols) \$__cols = ['—'];\n"
            . "?>" . $dynOpen . "<?php\n"
            . "foreach (\$__cols as \$__col) {\n"
            . "  \$__list = array_values(array_filter(\$__kb, static fn(\$r) => (string)(\$r['$gc'] ?? '') === (string)\$__col));\n"
            . "  \$__n = count(\$__list);\n"
            . "  echo $colOpenE . htmlspecialchars((string)\$__col) . $countE . '</div>';\n"
            . "  \$__c = 0; foreach (\$__list as \$__r) { if (\$__c++ >= $lim) break; echo $cardE; }\n"
            . "  if (!\$__n) echo '<div class=\"ppd-kanban-card is-empty\">$empty</div>';\n"
            . "  echo '</div>';\n"
            . "}\n"
            . "?>" . '</div>';
        return $php . ($dnd ? self::kanbanDndScript() : '');
    }

    /** Vanilla-JS drag & drop for dynamic Kanban boards. Delegated + idempotent
     *  (guarded by a window flag) so it works for any number of boards on a page
     *  and needs no library. Persists a move through api/save.php. */
    private static function kanbanDndScript(): string
    {
        return <<<'HTML'
<script>
(function(){
  if (window.__ppKanbanDnd) return; window.__ppKanbanDnd = 1;
  var drag = null, from = null;
  function recount(board){ board.querySelectorAll('.ppd-kanban-col').forEach(function(col){ var b=col.querySelector('.ppd-kanban-count'); if(b) b.textContent = col.querySelectorAll('.ppd-kanban-card:not(.is-empty)').length; }); }
  function fillEmpty(col, txt){ if(col && !col.querySelector('.ppd-kanban-card:not(.is-empty)') && !col.querySelector('.ppd-kanban-card.is-empty')){ var em=document.createElement('div'); em.className='ppd-kanban-card is-empty'; em.textContent=txt||'—'; col.appendChild(em); } }
  document.addEventListener('dragstart', function(e){ var c = e.target.closest ? e.target.closest('.ppd-kanban-card[draggable="true"]') : null; if(!c) return; drag=c; from=c.closest('.ppd-kanban-col'); c.classList.add('is-dragging'); if(e.dataTransfer){ e.dataTransfer.effectAllowed='move'; try{ e.dataTransfer.setData('text/plain',''); }catch(_){} } });
  document.addEventListener('dragend', function(){ if(drag) drag.classList.remove('is-dragging'); document.querySelectorAll('.ppd-kanban-col.is-over').forEach(function(x){ x.classList.remove('is-over'); }); drag=null; from=null; });
  document.addEventListener('dragover', function(e){ if(drag && e.target.closest && e.target.closest('.ppd-kanban-col')){ e.preventDefault(); if(e.dataTransfer) e.dataTransfer.dropEffect='move'; } });
  document.addEventListener('dragenter', function(e){ var col=e.target.closest?e.target.closest('.ppd-kanban-col'):null; if(col&&drag) col.classList.add('is-over'); });
  document.addEventListener('dragleave', function(e){ var col=e.target.closest?e.target.closest('.ppd-kanban-col'):null; if(col && !col.contains(e.relatedTarget)) col.classList.remove('is-over'); });
  document.addEventListener('drop', function(e){
    var col = e.target.closest ? e.target.closest('.ppd-kanban-col') : null; if(!col || !drag) return; e.preventDefault(); col.classList.remove('is-over');
    var board = col.closest('.ppd-kanban'); var src = from; if(!board || col===src) return;
    var em = col.querySelector('.ppd-kanban-card.is-empty'); if(em) em.remove();
    col.appendChild(drag); fillEmpty(src); recount(board);
    var table = board.getAttribute('data-kanban-table'), group = board.getAttribute('data-kanban-group'), id = drag.getAttribute('data-id'), val = col.getAttribute('data-col');
    if(!table || !group || !id) return;
    var body = 'table='+encodeURIComponent(table)+'&id='+encodeURIComponent(id)+'&'+encodeURIComponent(group)+'='+encodeURIComponent(val==null?'':val);
    fetch((window.PP_BASE||'')+'/api/save.php', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:body, credentials:'same-origin' })
      .then(function(r){ return r.json(); })
      .then(function(resp){ if(!resp || !resp.success){ alert((resp && resp.error) || 'Could not move the card.'); location.reload(); } })
      .catch(function(){ alert('Could not move the card.'); location.reload(); });
  });
})();
</script>
HTML;
    }

    /** Signature pad: canvas draw (mouse/touch, DPR-crisp) → base64 PNG into a hidden
     *  input so it submits with the form. Idempotent + delegated init; optional
     *  clear button and a required-on-submit guard. No library. */
    private static function signatureScript(): string
    {
        return <<<'HTML'
<script>
(function(){
  if (window.__ppSigInit) return; window.__ppSigInit = 1;
  function initField(field){
    if (field.__sig) return;
    var pad = field.querySelector('.ppd-sig'), canvas = field.querySelector('.ppd-sig-canvas'), input = field.querySelector('.ppd-sig-input');
    if (!pad || !canvas || !input) return; field.__sig = 1;
    var ctx = canvas.getContext('2d');
    var pen = pad.getAttribute('data-pen') || '#111827', w = parseFloat(pad.getAttribute('data-width')) || 2.5;
    var hist = [{ blank: true }], pos = 0, drawing = false, last = null, curDirty = false;
    function style(){ ctx.lineJoin = ctx.lineCap = 'round'; ctx.strokeStyle = pen; ctx.lineWidth = w; }
    function sizeCanvas(){ var r = canvas.getBoundingClientRect(), dpr = window.devicePixelRatio || 1; canvas.width = Math.max(1, Math.round(r.width * dpr)); canvas.height = Math.max(1, Math.round(r.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); style(); }
    function wipe(){ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore(); }
    function btn(k){ return field.querySelector('[data-sig="' + k + '"]'); }
    function sync(){ var s = hist[pos]; input.value = (s && !s.blank && s.url) ? s.url : ''; }
    function update(){ var u = btn('undo'), r = btn('redo'); if (u) u.disabled = pos <= 0; if (r) r.disabled = pos >= hist.length - 1; }
    function pushState(s){ hist = hist.slice(0, pos + 1); hist.push(s); pos = hist.length - 1; sync(); update(); }
    function restore(){ wipe(); var s = hist[pos]; if (s && !s.blank && s.url){ var img = new Image(); img.onload = function(){ var rr = canvas.getBoundingClientRect(); ctx.drawImage(img, 0, 0, rr.width, rr.height); }; img.src = s.url; } sync(); update(); }
    function xy(e){ var r = canvas.getBoundingClientRect(), t = (e.touches && e.touches[0]) ? e.touches[0] : e; return { x: t.clientX - r.left, y: t.clientY - r.top }; }
    function start(e){ drawing = true; curDirty = false; last = xy(e); e.preventDefault(); }
    function move(e){ if (!drawing) return; var p = xy(e); ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last = p; curDirty = true; e.preventDefault(); }
    function end(){ if (!drawing) return; drawing = false; if (curDirty) pushState({ blank: false, url: canvas.toDataURL('image/png') }); }
    canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', move); window.addEventListener('mouseup', end);
    canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchmove', move, { passive: false }); canvas.addEventListener('touchend', end);
    function flash(msg, err){ var m = field.querySelector('.ppd-sig-msg'); if (!m) return; m.textContent = msg; m.classList.toggle('is-err', !!err); clearTimeout(m.__t); m.__t = setTimeout(function(){ m.textContent = ''; m.classList.remove('is-err'); }, 2500); }
    function doSave(){
      sync();
      var table = pad.getAttribute('data-save-table'), keyCol = pad.getAttribute('data-save-key') || 'id', col = input.getAttribute('name') || 'signature';
      if (table){
        var form = field.closest('form'), qs = new URLSearchParams(location.search);
        function look(name){ var el = form && form.querySelector('[name="' + name + '"]'); if (el && el.value !== '') return el.value; return qs.get(name) || ''; }
        var id = look(keyCol) || look('id');
        if (!id){ flash('No record to update', true); return; }
        var sb = btn('save'); if (sb) sb.disabled = true;
        var body = 'table=' + encodeURIComponent(table) + '&key=' + encodeURIComponent(keyCol) + '&id=' + encodeURIComponent(id) + '&column=' + encodeURIComponent(col) + '&value=' + encodeURIComponent(input.value);
        fetch((window.PP_BASE || '') + '/api/signature.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body, credentials: 'same-origin' })
          .then(function(r){ return r.json(); })
          .then(function(resp){ var ok = resp && resp.success; flash(ok ? 'Saved' : ((resp && resp.error) || 'Save failed'), !ok); if (sb) sb.disabled = false; })
          .catch(function(){ flash('Save failed', true); if (sb) sb.disabled = false; });
      } else { flash('Saved'); }
    }
    field.addEventListener('click', function(e){
      var b = e.target.closest ? e.target.closest('[data-sig]') : null; if (!b || !field.contains(b)) return;
      var a = b.getAttribute('data-sig');
      if (a === 'undo'){ if (pos > 0){ pos--; restore(); } }
      else if (a === 'redo'){ if (pos < hist.length - 1){ pos++; restore(); } }
      else if (a === 'clear'){ if (!(hist[pos] && hist[pos].blank)) pushState({ blank: true }); wipe(); }
      else if (a === 'save'){ doSave(); }
    });
    var form = field.closest('form');
    if (form && input.getAttribute('data-required') === '1') form.addEventListener('submit', function(e){ if (!input.value){ e.preventDefault(); alert('Please provide your signature.'); } });
    sizeCanvas(); update();
  }
  function initAll(){ document.querySelectorAll('.ppd-sig-field').forEach(function(f){ if (f.querySelector('.ppd-sig-canvas')) initField(f); }); }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll); else initAll();
})();
</script>
HTML;
    }

    /** Live toast popup: slides in on load, auto-hides after data-toast-duration
     *  (0 = sticky), closable. Idempotent + delegated, no library. */
    private static function toastScript(): string
    {
        return <<<'HTML'
<script>
(function(){
  if (window.__ppToast) return; window.__ppToast = 1;
  function hide(c){ c.classList.remove('show'); setTimeout(function(){ if (c.parentNode) c.parentNode.removeChild(c); }, 320); }
  function show(c){ if (c.__t) return; c.__t = 1; requestAnimationFrame(function(){ c.classList.add('show'); }); var d = parseInt(c.getAttribute('data-toast-duration'), 10); if (d > 0) setTimeout(function(){ hide(c); }, d); }
  function initAll(){ document.querySelectorAll('.ppd-toastc').forEach(show); }
  document.addEventListener('click', function(e){ var b = e.target.closest ? e.target.closest('.ppd-toast-close') : null; if (!b) return; var c = b.closest('.ppd-toastc'); if (c) hide(c); });
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll); else initAll();
})();
</script>
HTML;
    }

    /** Shared "custom style" inline style for Alert / Toast — solid/gradient
     *  background, font, text/border/radius + --box-title / --box-icon vars.
     *  Every value is sanitized (cssColor / css / cssLen). */
    private static function customBoxStyle(array $p): string
    {
        $s = '';
        $bg1 = self::cssColor($p['bg'] ?? ''); $bg2 = self::cssColor($p['bg2'] ?? '');
        if (filter_var($p['gradient'] ?? false, FILTER_VALIDATE_BOOLEAN) && ($bg1 !== '' || $bg2 !== '')) {
            $angle = preg_replace('/[^0-9a-zA-Z%.\- ]/', '', (string) ($p['gradAngle'] ?? '')); if ($angle === '') $angle = '135deg';
            $s .= 'background:linear-gradient(' . $angle . ',' . ($bg1 !== '' ? $bg1 : '#6366f1') . ',' . ($bg2 !== '' ? $bg2 : ($bg1 !== '' ? $bg1 : '#8b5cf6')) . ');';
        } elseif ($bg1 !== '') {
            $s .= 'background:' . $bg1 . ';';
        }
        if (($v = self::css((string) ($p['font'] ?? ''))) !== '') $s .= 'font-family:' . $v . ';';
        if (($v = self::cssColor($p['textColor'] ?? '')) !== '') $s .= 'color:' . $v . ';';
        if (($v = self::cssColor($p['borderColor'] ?? '')) !== '') $s .= 'border-color:' . $v . ';';
        if (($v = self::cssLen($p['radius'] ?? '')) !== '') $s .= 'border-radius:' . $v . ';';
        if (($v = self::cssColor($p['titleColor'] ?? '')) !== '') $s .= '--box-title:' . $v . ';';
        if (($v = self::cssColor($p['iconColor'] ?? '')) !== '') $s .= '--box-icon:' . $v . ';';
        return $s;
    }

    /** Initials from a name: first letter of the first + last word (build-time). */
    private static function avatarInitials(string $name): string
    {
        $w = preg_split('/\s+/', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: [];
        if (!$w) return '?';
        return strtoupper(mb_substr($w[0], 0, 1) . (count($w) > 1 ? mb_substr($w[count($w) - 1], 0, 1) : ''));
    }

    /** Extract a 6-hex colour (no #) for the QR API; falls back to $default. */
    private static function qrHex($c, string $default): string
    {
        $c = ltrim((string) $c, '#');
        if (preg_match('/^[0-9A-Fa-f]{6}$/', $c)) return $c;
        if (preg_match('/^[0-9A-Fa-f]{3}$/', $c)) return preg_replace('/(.)/', '$1$1', $c);
        return $default;
    }

    private static function kpiValuePhp(array $p): string
    {
        $t   = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
        $col = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['column'] ?? ''));
        $agg = strtolower((string) ($p['agg'] ?? 'count'));
        if (!in_array($agg, ['count', 'sum', 'avg', 'min', 'max'], true)) $agg = 'count';
        if ($t === '' || ($agg !== 'count' && $col === '')) return '';
        $aggSql = $agg === 'count' ? 'COUNT(*)' : strtoupper($agg) . "(`$col`)";
        $w = trim(str_replace(["\0", ';'], '', (string) ($p['where'] ?? '')));
        $w = preg_replace('/^(where|and)\s+/i', '', $w) ?? $w;
        // WHERE expanded via SqlVars at request time so :userid / :today / :field.name work.
        $sqlExpr = $w === ''
            ? '"SELECT ' . $aggSql . ' FROM `' . $t . '`"'
            : '"SELECT ' . $aggSql . ' FROM `' . $t . '` WHERE (" . \PpPhp\Core\SqlVars::expand(' . var_export($w, true) . ') . ")"';
        $expr = "\\PpPhp\\Core\\DB::FetchOne($sqlExpr)";
        return '<?php $__k = (float) (' . $expr . ' ?: 0); echo htmlspecialchars(number_format($__k, $__k == (int) $__k ? 0 : 2)); ?>';
    }
}
