<?php

declare(strict_types=1);

namespace PpPhp\Core\Theme;

/**
 * ThemeCompiler
 *
 * Turns a resolved theme (token map + optional dark overrides) into a scoped
 * block of CSS custom properties (`--pp-*`). One compiler powers the designer
 * canvas preview, the Style SPA live preview, and the built application.
 *
 * Dark mode is emitted under `<scope>.is-dark` so toggling a single class on the
 * scoped element flips the palette (matches the per-theme dark-variant model).
 */
final class ThemeCompiler
{
    /** Canonical token key => CSS custom property. */
    private const VARMAP = [
        'bg' => '--pp-bg',
        'surface' => '--pp-surface',
        'surfaceSoft' => '--pp-surface-soft',
        'text' => '--pp-text',
        'muted' => '--pp-muted',
        'border' => '--pp-border',
        'primary' => '--pp-primary',
        'primarySoft' => '--pp-primary-soft',
        'primaryText' => '--pp-primary-text',
        'success' => '--pp-success',
        'warning' => '--pp-warning',
        'danger' => '--pp-danger',
        'info' => '--pp-info',
        'radius' => '--pp-radius',
        'shadow' => '--pp-shadow',
        'font' => '--pp-font',
        'mono' => '--pp-mono',
    ];

    /**
     * Extra CSS variables emitted alongside a token, so a theme also drives the
     * designer's existing `--ppd-*` component tokens (234+ usages) without
     * touching component CSS. Scoped to the themed element, this re-themes the
     * live designer canvas and the Style preview for free.
     */
    private const ALIAS = [
        'bg' => ['--ppd-bg'],
        'surface' => ['--ppd-surface'],
        'surfaceSoft' => ['--ppd-soft'],
        'text' => ['--ppd-text'],
        'muted' => ['--ppd-muted'],
        'border' => ['--ppd-border', '--ppd-border-soft'],
        'primary' => ['--ppd-primary'],
        'primarySoft' => ['--ppd-primary-soft', '--ppd-primary-border'],
        'danger' => ['--ppd-danger'],
        'success' => ['--ppd-ok'],
        'shadow' => ['--ppd-shadow'],
    ];

    /** Named colour schemes for the app shell (topbar / sidebar / logo). */
    public const SHELL_PALETTES = [
        'navy'    => ['bg' => '#0f172a', 'text' => '#cbd5e1', 'active' => '#ffffff', 'accent' => '#3b82f6'],
        'dark'    => ['bg' => '#1e293b', 'text' => '#cbd5e1', 'active' => '#ffffff', 'accent' => '#60a5fa'],
        'slate'   => ['bg' => '#334155', 'text' => '#e2e8f0', 'active' => '#ffffff', 'accent' => '#93c5fd'],
        'light'   => ['bg' => '#f1f5f9', 'text' => '#475569', 'active' => '#0f172a', 'accent' => '#2563eb'],
        'white'   => ['bg' => '#ffffff', 'text' => '#475569', 'active' => '#0f172a', 'accent' => '#2563eb'],
        'primary' => ['bg' => 'var(--pp-primary)', 'text' => '#ffffff', 'active' => '#ffffff', 'accent' => 'rgba(255,255,255,.85)'],
    ];
    private const DENSITY_PAD = ['compact' => '6px', 'normal' => '10px', 'comfortable' => '15px'];

    /**
     * Baseline dark palette merged UNDER a theme's own dark tokens, so every
     * theme — including customs whose dark map is empty or partial — renders
     * a complete, attractive dark mode. Brand/status tokens are deliberately
     * omitted: they keep the theme's light values unless the theme overrides.
     */
    private const DEFAULT_DARK = [
        'bg' => '#0f172a',
        'surface' => '#1e293b',
        'surfaceSoft' => '#26334a',
        'text' => '#e2e8f0',
        'muted' => '#94a3b8',
        'border' => '#334155',
        'primarySoft' => 'rgba(96,165,250,0.16)',
        'shadow' => '0 12px 32px rgba(0,0,0,0.45)',
    ];

    /**
     * Fallbacks for base colours a theme may not define, so derivation always
     * has something to work from. `info` is newer than most saved themes.
     */
    private const BASE_FALLBACK = [
        'bg' => '#f8fafc', 'surface' => '#ffffff', 'surfaceSoft' => '#f1f5f9',
        'text' => '#0f172a', 'muted' => '#64748b', 'border' => '#e2e8f0',
        'primary' => '#2563eb', 'primaryText' => '#ffffff',
        'success' => '#16a34a', 'warning' => '#d97706', 'danger' => '#dc2626', 'info' => '#0284c7',
    ];

    /** The token keys a complete theme is expected to define. */
    public static function tokenKeys(): array
    {
        return array_keys(self::VARMAP);
    }

    /**
     * The CSS properties a designer authors directly on the Theme tab, as
     * opposed to the ~74 the compiler derives from them.
     *
     * Note this returns PROPERTY names (`--pp-surface-soft`), not the camelCase
     * field keys `tokenKeys()` returns (`surfaceSoft`).
     *
     * @return list<string>
     */
    public static function authoredProperties(): array
    {
        return array_values(self::VARMAP);
    }

    /**
     * Every `--pp-*` custom property the runtime may reference, including the
     * derived ones. Used by the Style module to document/validate the palette.
     *
     * @return array<string, list<string>> group => properties
     */
    public static function tokenCatalogue(): array
    {
        return [
            'Base'        => ['--pp-bg', '--pp-surface', '--pp-surface-soft', '--pp-surface-raised', '--pp-surface-sunken', '--pp-text', '--pp-muted', '--pp-border', '--pp-border-strong'],
            'Brand'       => ['--pp-primary', '--pp-primary-soft', '--pp-primary-text', '--pp-primary-hover', '--pp-primary-border', '--pp-link', '--pp-link-hover'],
            'Status'      => ['--pp-success', '--pp-success-soft', '--pp-success-border', '--pp-success-text',
                              '--pp-warning', '--pp-warning-soft', '--pp-warning-border', '--pp-warning-text',
                              '--pp-danger', '--pp-danger-soft', '--pp-danger-border', '--pp-danger-text',
                              '--pp-info', '--pp-info-soft', '--pp-info-border', '--pp-info-text'],
            'Forms'       => ['--pp-input-bg', '--pp-input-border', '--pp-input-text', '--pp-placeholder', '--pp-focus', '--pp-focus-ring', '--pp-disabled-bg', '--pp-disabled-text'],
            'Data grid'   => ['--pp-grid-panel', '--pp-grid-bg', '--pp-header-bg', '--pp-row-hover', '--pp-row-stripe', '--pp-row-selected', '--pp-code-bg', '--pp-code-text', '--pp-track', '--pp-selection'],
            'Elevation'   => ['--pp-shadow', '--pp-shadow-sm', '--pp-shadow-md', '--pp-shadow-lg', '--pp-scrim', '--pp-overlay'],
            'Tooltip'     => ['--pp-tooltip-bg', '--pp-tooltip-text', '--pp-tooltip-border'],
            'Shell'       => ['--pp-topbar-bg', '--pp-topbar-text', '--pp-topbar-accent', '--pp-topbar-h',
                              '--pp-topbar-hover', '--pp-topbar-active-bg', '--pp-topbar-border', '--pp-topbar-scrollbar',
                              '--pp-sidebar-bg', '--pp-sidebar-text', '--pp-sidebar-active', '--pp-sidebar-accent', '--pp-sidebar-w',
                              '--pp-sidebar-hover', '--pp-sidebar-active-bg', '--pp-sidebar-border', '--pp-sidebar-scrollbar',
                              '--pp-logo-bg', '--pp-logo-text', '--pp-on-shell'],
            'Shape/type'  => ['--pp-radius', '--pp-radius-sm', '--pp-radius-lg', '--pp-font', '--pp-mono', '--pp-density-pad'],
            'File format' => ['--pp-fmt-csv', '--pp-fmt-excel', '--pp-fmt-word', '--pp-fmt-json', '--pp-fmt-xml', '--pp-fmt-pdf'],
        ];
    }

    /**
     * One-line purpose for every catalogued property.
     *
     * The Palette panel shows these next to each swatch so a developer can tell
     * what a token is FOR without reading the compiler. Keep them short and
     * concrete — "where does this colour actually land in the UI".
     *
     * @return array<string, string> property => description
     */
    public static function tokenDocs(): array
    {
        return [
            // Base
            '--pp-bg'             => 'Page background behind all panels.',
            '--pp-surface'        => 'Default panel/card background.',
            '--pp-surface-soft'   => 'Subtly tinted surface for grouped sections.',
            '--pp-surface-raised' => 'Surface that sits above the page — menus, popovers, modals.',
            '--pp-surface-sunken' => 'Recessed surface — wells, code blocks, inset areas.',
            '--pp-text'           => 'Primary body text.',
            '--pp-muted'          => 'Secondary text — captions, hints, disabled labels.',
            '--pp-border'         => 'Default hairline border between elements.',
            '--pp-border-strong'  => 'Heavier border for emphasis or separation.',
            // Brand
            '--pp-primary'        => 'Brand colour — primary buttons, active states, links.',
            '--pp-primary-soft'   => 'Tinted brand wash for chips and selected rows.',
            '--pp-primary-text'   => 'Text/icon colour that sits ON the primary colour.',
            '--pp-primary-hover'  => 'Primary colour on hover.',
            '--pp-primary-border' => 'Border for primary-toned controls.',
            '--pp-link'           => 'Inline link colour.',
            '--pp-link-hover'     => 'Inline link colour on hover.',
            // Status
            '--pp-success'        => 'Success accent — confirmations, positive badges.',
            '--pp-success-soft'   => 'Success background wash for alerts and chips.',
            '--pp-success-border' => 'Border for success alerts and chips.',
            '--pp-success-text'   => 'Readable text on a success wash (contrast-checked).',
            '--pp-warning'        => 'Warning accent — caution states.',
            '--pp-warning-soft'   => 'Warning background wash.',
            '--pp-warning-border' => 'Border for warning alerts and chips.',
            '--pp-warning-text'   => 'Readable text on a warning wash (contrast-checked).',
            '--pp-danger'         => 'Danger accent — destructive actions, errors.',
            '--pp-danger-soft'    => 'Danger background wash.',
            '--pp-danger-border'  => 'Border for danger alerts and chips.',
            '--pp-danger-text'    => 'Readable text on a danger wash (contrast-checked).',
            '--pp-info'           => 'Informational accent — neutral notices.',
            '--pp-info-soft'      => 'Info background wash.',
            '--pp-info-border'    => 'Border for info alerts and chips.',
            '--pp-info-text'      => 'Readable text on an info wash (contrast-checked).',
            // Forms
            '--pp-input-bg'       => 'Background of text inputs, selects and textareas.',
            '--pp-input-border'   => 'Resting border of form controls.',
            '--pp-input-text'     => 'Text typed into a form control.',
            '--pp-placeholder'    => 'Placeholder text inside an empty control.',
            '--pp-focus'          => 'Border colour of the focused control.',
            '--pp-focus-ring'     => 'Glow ring around the focused control.',
            '--pp-disabled-bg'    => 'Background of a disabled control.',
            '--pp-disabled-text'  => 'Text of a disabled control.',
            // Data grid
            '--pp-grid-panel'     => 'Background of the panel wrapping a data grid.',
            '--pp-grid-bg'        => 'Background of the grid body itself.',
            '--pp-header-bg'      => 'Grid header row background.',
            '--pp-row-hover'      => 'Row background under the cursor.',
            '--pp-row-stripe'     => 'Background of alternating (zebra) rows.',
            '--pp-row-selected'   => 'Background of a checked/selected row.',
            '--pp-code-bg'        => 'Background of inline code and pre blocks.',
            '--pp-code-text'      => 'Text colour of inline code.',
            '--pp-track'          => 'Track behind progress bars and scrollbars.',
            '--pp-selection'      => 'Highlight for text/cell selection.',
            // Elevation
            '--pp-shadow'         => 'Default resting shadow.',
            '--pp-shadow-sm'      => 'Subtle shadow — cards, toolbars.',
            '--pp-shadow-md'      => 'Medium shadow — dropdowns, popovers.',
            '--pp-shadow-lg'      => 'Deep shadow — modals, expanded rails.',
            '--pp-scrim'          => 'Full-screen dim behind a modal.',
            '--pp-overlay'        => 'Lighter veil for inline loading states.',
            // Tooltip
            '--pp-tooltip-bg'     => 'Tooltip bubble — deliberately inverted against the page.',
            '--pp-tooltip-text'   => 'Tooltip label (contrast-checked against the bubble).',
            '--pp-tooltip-border' => 'Hairline edge on the tooltip bubble.',
            // Shell
            '--pp-topbar-bg'         => 'Topbar background.',
            '--pp-topbar-text'       => 'Topbar text and icons.',
            '--pp-topbar-accent'     => 'Accent used for the active topbar item.',
            '--pp-topbar-h'          => 'Topbar height.',
            '--pp-topbar-hover'      => 'Topbar item hover veil (flips with topbar luminance).',
            '--pp-topbar-active-bg'  => 'Background of the active topbar item.',
            '--pp-topbar-border'     => 'Divider under the topbar.',
            '--pp-topbar-scrollbar'  => 'Scrollbar thumb inside the topbar.',
            '--pp-sidebar-bg'        => 'Sidebar background.',
            '--pp-sidebar-text'      => 'Sidebar menu text.',
            '--pp-sidebar-active'    => 'Text colour of the active sidebar item.',
            '--pp-sidebar-accent'    => 'Accent bar/marker on the active sidebar item.',
            '--pp-sidebar-w'         => 'Sidebar width.',
            '--pp-sidebar-hover'     => 'Sidebar item hover veil (flips with sidebar luminance).',
            '--pp-sidebar-active-bg' => 'Background of the active sidebar item.',
            '--pp-sidebar-border'    => 'Divider between sidebar and content.',
            '--pp-sidebar-scrollbar' => 'Scrollbar thumb inside the sidebar.',
            '--pp-logo-bg'           => 'Background behind the logo block.',
            '--pp-logo-text'         => 'Logo text colour.',
            '--pp-on-shell'          => 'Text that sits on any shell accent.',
            // Shape / type
            '--pp-radius'      => 'Default corner radius.',
            '--pp-radius-sm'   => 'Small radius — chips, inputs, tight controls.',
            '--pp-radius-lg'   => 'Large radius — cards, modals.',
            '--pp-font'        => 'UI font stack.',
            '--pp-mono'        => 'Monospace font stack for code and values.',
            '--pp-density-pad' => 'Base padding step that Density scales.',
            // File format
            '--pp-fmt-csv'   => 'Brand tint for CSV files.',
            '--pp-fmt-excel' => 'Brand tint for Excel files.',
            '--pp-fmt-word'  => 'Brand tint for Word files.',
            '--pp-fmt-json'  => 'Brand tint for JSON files.',
            '--pp-fmt-xml'   => 'Brand tint for XML files.',
            '--pp-fmt-pdf'   => 'Brand tint for PDF files.',
        ];
    }

    /**
     * Derive the full palette from a theme's base colours.
     *
     * Everything here is computed, never authored, so a theme that only sets
     * `primary` + `surface` still yields readable status chips, hover rows and
     * focus rings — in light AND dark. This is what lets component CSS drop
     * hardcoded colours entirely.
     *
     * @param array<string,string> $p resolved base palette (light or dark)
     * @return array<string,string> css property => value
     */
    private static function derive(array $p): array
    {
        $get = static fn (string $k): string => trim((string) ($p[$k] ?? self::BASE_FALLBACK[$k] ?? ''));

        $surface = $get('surface');
        $text    = $get('text');
        $primary = $get('primary');
        $border  = $get('border');
        $dark    = Color::isDark($surface);
        $out     = [];

        // ── surfaces ─────────────────────────────────────────────────────────
        // Raised lifts AWAY from the page in dark mode and stays flat in light,
        // matching how elevation actually reads in each scheme.
        $out['--pp-surface-raised'] = $dark ? Color::mix('#ffffff', $surface, 0.07) : $surface;
        $out['--pp-surface-sunken'] = $dark ? Color::mix('#000000', $surface, 0.14) : Color::mix($text, $surface, 0.04);
        $out['--pp-border-strong']  = Color::mix($text, $border, 0.35);

        // ── brand ────────────────────────────────────────────────────────────
        $out['--pp-primary-hover']  = $dark ? Color::shade($primary, 0.12) : Color::shade($primary, -0.12);
        $out['--pp-primary-border'] = Color::mix($primary, $surface, 0.40);
        $out['--pp-link']           = Color::contrastOn($primary, $surface, 3.5);
        $out['--pp-link-hover']     = $out['--pp-primary-hover'];

        // ── status triads: soft fill, border, readable text ──────────────────
        foreach (['success', 'warning', 'danger', 'info'] as $sem) {
            $base = $get($sem);
            if ($base === '' || !Color::parse($base)) continue;
            $out["--pp-$sem"]          = $base;
            $out["--pp-$sem-soft"]     = Color::mix($base, $surface, $dark ? 0.22 : 0.12);
            $out["--pp-$sem-border"]   = Color::mix($base, $surface, $dark ? 0.45 : 0.32);
            $out["--pp-$sem-text"]     = Color::contrastOn($base, $out["--pp-$sem-soft"], 4.0);
            $out["--pp-on-$sem"]       = Color::readable($base);
        }

        // ── forms ────────────────────────────────────────────────────────────
        $out['--pp-input-bg']      = $dark ? Color::mix('#000000', $surface, 0.10) : $surface;
        $out['--pp-input-border']  = $border;
        $out['--pp-input-text']    = $text;
        $out['--pp-placeholder']   = Color::mix($get('muted'), $surface, 0.75);
        $out['--pp-focus']         = $primary;
        $out['--pp-focus-ring']    = Color::alpha($primary, 0.35);
        $out['--pp-disabled-bg']   = Color::mix($text, $surface, 0.06);
        $out['--pp-disabled-text'] = Color::mix($text, $surface, 0.38);

        // ── data grid ────────────────────────────────────────────────────────
        $out['--pp-header-bg']    = Color::mix($text, $surface, $dark ? 0.10 : 0.05);
        $out['--pp-row-hover']    = Color::mix($primary, $surface, $dark ? 0.14 : 0.06);
        $out['--pp-row-stripe']   = Color::mix($text, $surface, 0.03);
        $out['--pp-row-selected'] = Color::mix($primary, $surface, $dark ? 0.24 : 0.12);
        $out['--pp-code-bg']      = $out['--pp-surface-sunken'];

        // ── elevation ────────────────────────────────────────────────────────
        $shadowRgb = $dark ? '#000000' : Color::mix($text, '#000000', 0.5);
        $out['--pp-shadow-sm'] = '0 1px 2px ' . Color::alpha($shadowRgb, $dark ? 0.45 : 0.06);
        $out['--pp-shadow-md'] = '0 8px 24px ' . Color::alpha($shadowRgb, $dark ? 0.52 : 0.12);
        $out['--pp-shadow-lg'] = '0 18px 44px ' . Color::alpha($shadowRgb, $dark ? 0.60 : 0.16);
        $out['--pp-scrim']     = Color::alpha($dark ? '#000000' : $text, $dark ? 0.62 : 0.45);
        // Lighter veil for inline loading states (content stays readable behind).
        $out['--pp-overlay']   = Color::alpha($dark ? '#000000' : $text, $dark ? 0.45 : 0.32);

        // ── tooltip ──────────────────────────────────────────────────────────
        // A tooltip is an INVERTED surface by design: it must read as an overlay
        // floating above the page, not as another panel. So the bubble is built
        // from the text colour (dark in a light theme, light in a dark one) and
        // the label is contrast-checked against it — never against the page.
        // Keyed off the ACTUAL surface luminance, not the light/dark flag: a
        // "light" theme can still carry a dark surface (e.g. Dracula), and such a
        // theme needs a light bubble in light mode. Same lesson as the grid fix.
        $tipBg = Color::isDark($surface)
            ? Color::mix($text, $surface, 0.86)      // light bubble on a dark page
            : Color::mix($text, '#000000', 0.82);    // dark bubble on a light page
        $out['--pp-tooltip-bg']     = $tipBg;
        $out['--pp-tooltip-text']   = Color::contrastOn($surface, $tipBg, 7.0);
        $out['--pp-tooltip-border'] = Color::alpha($dark ? '#000000' : '#ffffff', 0.12);

        // ── misc surfaces referenced by components ───────────────────────────
        $out['--pp-track']      = Color::mix($text, $surface, $dark ? 0.16 : 0.08);   // progress/scroll track
        $out['--pp-code-text']  = Color::contrastOn($get('muted'), $out['--pp-code-bg'] ?? $surface, 4.0);
        $out['--pp-on-shell']   = '#ffffff';                                          // text over shell accents
        $out['--pp-selection']  = Color::alpha($primary, $dark ? 0.28 : 0.14);        // selected cell/part

        // ── file-format accents (Data menu, export tiles) ────────────────────
        // Tokenised so a theme can restyle them; defaults are the familiar
        // vendor colours, nudged to stay legible on the current surface.
        foreach (['csv' => '#0d9488', 'excel' => '#217346', 'word' => '#2b579a', 'json' => '#f59e0b', 'xml' => '#7c3aed', 'pdf' => '#e2483d'] as $k => $hex) {
            $out["--pp-fmt-$k"] = Color::contrastOn($hex, $surface, 3.0);
        }

        return array_filter($out, static fn ($v) => trim((string) $v) !== '');
    }

    public static function shellPaletteNames(): array
    {
        return array_keys(self::SHELL_PALETTES);
    }

    public static function css(array $theme, string $scope = ':root'): string
    {
        $scope = trim($scope) === '' ? ':root' : trim($scope);
        $lightTokens = (array) ($theme['tokens'] ?? []);
        $light = self::block($scope, $lightTokens, $lightTokens);
        $shell = self::shellBlock($scope, $theme);

        // Always emit a complete dark block: the theme's dark tokens layered
        // over a baseline dark palette, then rebind the grid-surface vars —
        // the Grid tab's panel colours are light-mode values; without this
        // tables keep white rows while the text flips light (unreadable).
        $darkTokens = array_replace(self::DEFAULT_DARK, array_filter(
            (array) ($theme['dark'] ?? []),
            static fn ($v) => trim((string) $v) !== ''
        ));
        // Derive dark from the EFFECTIVE palette: DEFAULT_DARK deliberately omits
        // brand/status colours, which therefore still come from the light tokens.
        // Deriving from $darkTokens alone would tint status chips against the
        // wrong surface and produce unreadable text.
        $dark = self::block($scope . '.is-dark', $darkTokens, array_replace($lightTokens, $darkTokens));
        $dark .= "\n" . $scope . ".is-dark {\n"
            . "  --pp-grid-panel: var(--pp-surface);\n"
            . "  --pp-grid-bg: var(--pp-surface);\n"
            . "}";

        // Optional "Glass" surface style (Style → Appearance). Emitted only when the
        // theme opts in, so a normal (flat) theme is byte-identical to before.
        $glass = self::glassBlock($scope, $theme);

        // Bridge Bootstrap's semantic classes to the theme palette so a non-blue
        // theme (Catppuccin, Rose, Emerald…) re-skins every `.btn-primary`,
        // `.btn-success`, badge and pagination — not just our own `.pp-*` controls.
        // Glass themes get a richer scoped treatment in glassBlock (higher
        // specificity), so this base bridge only governs flat themes.
        $bridge = self::bootstrapBridge();

        return trim($light . "\n" . $bridge . ($shell !== '' ? "\n" . $shell : '') . "\n" . $dark
            . ($glass !== '' ? "\n" . $glass : ''));
    }

    /**
     * Map Bootstrap 5.3 button/utility classes onto the `--pp-*` palette via the
     * framework's own `--bs-*` custom properties (so hover/active/disabled states
     * follow too). Emitted once; because it references `var(--pp-*)`, it flips for
     * dark automatically. Fallback colours keep the rule valid if a status token
     * is ever undefined (an undefined var in color-mix would drop the whole rule).
     */
    private static function bootstrapBridge(): string
    {
        $btn = static function (string $cls, string $var, string $fb) {
            $c = "var($var,$fb)";
            $d = static fn (int $p) => "color-mix(in srgb,$c $p%,#000)";
            return ".$cls{--bs-btn-bg:$c;--bs-btn-border-color:$c;--bs-btn-hover-bg:{$d(87)};"
                . "--bs-btn-hover-border-color:{$d(84)};--bs-btn-active-bg:{$d(80)};"
                . "--bs-btn-active-border-color:{$d(78)};--bs-btn-disabled-bg:$c;"
                . "--bs-btn-disabled-border-color:$c}";
        };
        $p = 'var(--pp-primary,#2563eb)';
        $pt = 'var(--pp-primary-text,#fff)';
        return
            ".btn-primary{--bs-btn-bg:$p;--bs-btn-border-color:$p;--bs-btn-color:$pt;"
            . "--bs-btn-hover-bg:color-mix(in srgb,$p 87%,#000);--bs-btn-hover-border-color:color-mix(in srgb,$p 84%,#000);"
            . "--bs-btn-hover-color:$pt;--bs-btn-active-bg:color-mix(in srgb,$p 80%,#000);"
            . "--bs-btn-active-border-color:color-mix(in srgb,$p 78%,#000);--bs-btn-active-color:$pt;"
            . "--bs-btn-disabled-bg:$p;--bs-btn-disabled-border-color:$p;--bs-btn-disabled-color:$pt}\n"
            . ".btn-outline-primary{--bs-btn-color:$p;--bs-btn-border-color:$p;--bs-btn-hover-bg:$p;"
            . "--bs-btn-hover-border-color:$p;--bs-btn-hover-color:$pt;--bs-btn-active-bg:$p;"
            . "--bs-btn-active-border-color:$p;--bs-btn-active-color:$pt}\n"
            . $btn('btn-success', '--pp-success', '#16a34a') . "\n"
            . $btn('btn-danger', '--pp-danger', '#dc2626') . "\n"
            . $btn('btn-warning', '--pp-warning', '#d97706') . "\n"
            . $btn('btn-info', '--pp-info', $p) . "\n"
            . ".text-primary{color:$p!important}.bg-primary{background-color:$p!important}"
            . ".link-primary{color:$p!important}\n"
            . ".badge.bg-primary,.badge.text-bg-primary{background-color:$p!important;color:$pt!important}\n"
            . ".form-check-input:checked{background-color:$p;border-color:$p}\n"
            . ".nav-pills .nav-link.active,.nav-pills .show>.nav-link{background-color:$p}\n"
            . ".page-link{color:$p}.page-item.active .page-link{background-color:$p;border-color:$p}\n"
            . ".form-control,.form-select{background-color:var(--pp-surface,#fff);border-color:var(--pp-border,#e2e8f0);color:var(--pp-text,#0f172a)}"
            . ".form-control::placeholder{color:var(--pp-muted,#64748b)}"
            . ".form-control:focus,.form-select:focus{border-color:color-mix(in srgb,$p 55%,transparent);box-shadow:0 0 0 .2rem color-mix(in srgb,$p 22%,transparent)}";
    }

    /** True when this theme opts into the frosted-glass surface style. */
    public static function isGlass(array $theme): bool
    {
        return ($theme['surface'] ?? 'flat') === 'glass';
    }

    /**
     * The frosted-glass surface recipe. Re-skins the standard control set —
     * buttons, fields, cards, menus, tabs, toasts, modals — as translucent glass
     * with a chromatic-refraction edge. Everything is driven by the theme's own
     * `--pp-*` tokens through color-mix(), so it adapts to light/dark and any
     * palette automatically. Degrades to opaque surfaces where backdrop-filter is
     * unavailable or the viewer prefers reduced transparency. Emitted only for a
     * glass theme; returns '' otherwise.
     */
    private static function glassBlock(string $scope, array $theme): string
    {
        if (!self::isGlass($theme)) {
            return '';
        }
        $s = trim($scope) === '' ? ':root' : trim($scope);
        $sel = static fn (array $c): string => implode(', ', array_map(static fn ($x) => $s . ' ' . $x, $c));

        // The generated app uses its OWN design-system classes (pp-*), not only
        // Bootstrap's — so target both, or the glass never lands on the real
        // dashboard tiles, cards, fields and buttons.
        // Frosted panel surfaces across every page type — dashboard, list/table,
        // forms, calendar, overlays.
        $surfaces = ['.card', '.dropdown-menu', '.modal-content', '.toast', '.list-group', '.offcanvas',
            '.pp-card', '.pp-mc', '.pp-master-card', '.pp-record-card', '.pp-detail-grid',
            '.pp-menu-page .ppd-stat', '.ppd-dashboard-widget', '.ppd-shell-main section',
            '.pp-form-body', '.pp-form-modal-frame', '.pp-form-modal-dialog',
            '.pp-quick', '.pp-fold', '.pp-dd', '.pp-act-pop', '.pp-cols-menu', '.pp-filter-menu', '.pp-export-menu',
            '.pp-notif-menu', '.pp-notif-toast', '.pp-notif-item', '.pp-vsummary',
            '.pp-prof-card', '.pp-pwd-card', '.pp2fa-form',
            '.ppd-grid-table-wrap', '.ppd-grid-pagination', '.pp-pg', '.pp-list-footer', '.ppd-grid-empty',
            '.ppcal-callist', '.ppcal-cats', '.ppcal-agenda', '.ppcal-mini'];
        $inputs = ['.form-control', '.form-select', '.input-group-text', '.ppd-grid-search',
            '.pp-input', '.pp-fld', '.pp-inline-input', '.ppcal-input'];
        $buttons = ['.btn', '.pp-btn-form', '.pp-mc-btn', '.pp-b2', '.ppd-grid-toolbar-btn', '.pp-inline-save', '.pp-act-more', '.ppcal-btn'];
        // Soft (translucent) buttons — must EXCLUDE the primary CTAs, else their
        // solid --pp-primary fill is overwritten with glass-soft and vanishes.
        $buttonsSoft = ['.btn:not(.btn-primary):not([class*="btn-outline"])', '.pp-mc-btn', '.pp-b2', '.ppd-grid-toolbar-btn', '.ppcal-btn:not(.ppcal-primary)'];
        $refractCls = ['.card', '.modal-content', '.pp-card', '.pp-mc', '.pp-master-card', '.pp-record-card', '.pp-menu-page .ppd-stat', '.pp-hero', '.ppd-grid-table-wrap', '.btn-primary', '.ppcal-primary'];
        // Content containers whose solid --pp-bg would hide the aurora — cleared
        // so the ONE fixed body aurora shows through the whole app.
        $clear = ['.pp-shell', '.ppd-shell-main', '.pp-main', '.pp-menu-page', '.pp-content', '.pp-list-wrap'];
        // Shell chrome — a tinted frosted glass (keeps text contrast, still glassy).
        $shell = ['.pp-sidebar', '.ppd-shell-sidebar', '.pp-topbar'];
        // Table internals go transparent so the wrap's single glass panel shows through.
        $tableClear = ['.ppd-grid-table', '.ppd-grid-table tbody tr', '.ppd-grid-table td',
            '.ppd-grid-table.pp-striped tbody tr:nth-child(odd)'];
        $tableHead = ['.ppd-grid-table thead th', '.ppd-grid-table.pp-sticky thead th'];
        $tableHover = ['.ppd-grid-table.pp-hover tbody tr:hover'];

        $surf = $sel($surfaces);
        $inp = $sel($inputs);
        $btn = $sel($buttons);
        $btnSoft = $sel($buttonsSoft);
        $refract = $sel($refractCls);
        $clearSel = $sel($clear);
        $shellSel = $sel($shell);
        $tableClearSel = $sel($tableClear);
        $tableHeadSel = $sel($tableHead);
        $tableHoverSel = $sel($tableHover);
        $refractAfter = implode(', ', array_map(static fn ($x) => $s . ' ' . $x . '::after', $refractCls));

        // Prominent standalone cards that earn the full "liquid glass" edge
        // (masked specular rim + floating glow + refraction pool). The form card
        // is matched via `.ppg-form > .pp-form-body` so the transparent nested
        // grid inside is excluded; the others are plain top-level cards.
        $liquidCards = ['.ppg-form > .pp-form-body', '.pp-record-card', '.pp-master-card', '.pp-vsummary', '.pp-prof-card', '.pp-pwd-card', '.pp2fa-form'];
        $liqSel = implode(', ', array_map(static fn ($x) => $s . ' ' . $x, $liquidCards));
        $liqBefore = implode(', ', array_map(static fn ($x) => $s . ' ' . $x . '::before', $liquidCards));
        $liqAfter = implode(', ', array_map(static fn ($x) => $s . ' ' . $x . '::after', $liquidCards));
        $liqDarkSel = implode(', ', array_map(static fn ($x) => $s . '.is-dark ' . $x, $liquidCards));
        $liqDarkBefore = implode(', ', array_map(static fn ($x) => $s . '.is-dark ' . $x . '::before', $liquidCards));

        return <<<CSS
{$s} {
  --pp-glass-blur: 22px;
  --pp-glass-refract: linear-gradient(100deg,#ff5db4,#a07bff,#37cfff,#5dffa8);
  --pp-glass-face: linear-gradient(135deg, rgba(255,255,255,.46) 0%, rgba(255,255,255,.18) 46%, rgba(255,255,255,.08) 100%);
  --pp-glass-soft: linear-gradient(135deg, rgba(255,255,255,.32), rgba(255,255,255,.10));
  --pp-glass-edge: rgba(255,255,255,.44);
  --pp-glass-edge-soft: rgba(255,255,255,.26);
  --pp-glass-shell: color-mix(in srgb, var(--pp-primary) 58%, transparent);
  --pp-glass-hero: color-mix(in srgb, var(--pp-primary) 64%, transparent);
  --pp-glass-gloss: inset 0 1px 0 rgba(255,255,255,.6), inset 0 -18px 28px rgba(255,255,255,.07);
  --pp-glass-shadow: 0 14px 38px rgba(23,26,64,.15);
}
{$s}.is-dark {
  --pp-glass-face: linear-gradient(135deg, rgba(255,255,255,.17) 0%, rgba(255,255,255,.06) 46%, rgba(255,255,255,.03) 100%);
  --pp-glass-soft: linear-gradient(135deg, rgba(255,255,255,.12), rgba(255,255,255,.03));
  --pp-glass-edge: rgba(255,255,255,.15);
  --pp-glass-edge-soft: rgba(255,255,255,.10);
  --pp-glass-shell: color-mix(in srgb, var(--pp-primary) 46%, rgba(10,12,24,.55));
  --pp-glass-hero: color-mix(in srgb, var(--pp-primary) 52%, rgba(10,12,24,.4));
  --pp-glass-gloss: inset 0 1px 0 rgba(255,255,255,.18), inset 0 -18px 28px rgba(255,255,255,.03);
  --pp-glass-shadow: 0 16px 42px rgba(0,0,0,.42);
}
{$s} body {
  background-color: var(--pp-bg);
  background-image:
    radial-gradient(42% 48% at 16% 8%, color-mix(in srgb, var(--pp-primary) 62%, transparent), transparent 60%),
    radial-gradient(38% 44% at 88% 4%, rgba(56,207,255,.5), transparent 58%),
    radial-gradient(48% 54% at 90% 88%, rgba(168,110,255,.5), transparent 60%),
    radial-gradient(42% 48% at 6% 94%, rgba(255,110,180,.42), transparent 58%),
    radial-gradient(60% 66% at 48% 46%, color-mix(in srgb, var(--pp-primary) 26%, transparent), transparent 72%);
  background-attachment: fixed;
}
{$s}.is-dark body {
  background-image:
    radial-gradient(44% 50% at 16% 8%, color-mix(in srgb, var(--pp-primary) 82%, transparent), transparent 58%),
    radial-gradient(40% 46% at 88% 4%, rgba(56,207,255,.36), transparent 56%),
    radial-gradient(50% 56% at 90% 88%, rgba(150,90,255,.46), transparent 58%),
    radial-gradient(44% 50% at 6% 94%, rgba(255,90,170,.32), transparent 56%),
    radial-gradient(64% 70% at 48% 46%, color-mix(in srgb, var(--pp-primary) 34%, transparent), transparent 72%);
}
{$clearSel} { background: transparent !important; background-image: none !important; }
{$shellSel} {
  background: var(--pp-glass-shell) !important;
  -webkit-backdrop-filter: blur(24px) saturate(160%);
  backdrop-filter: blur(24px) saturate(160%);
  border-color: var(--pp-glass-edge) !important;
}
{$s} .pp-footer {
  background: var(--pp-glass-soft); border-top: 1px solid var(--pp-glass-edge);
  -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
}
{$surf} {
  background: var(--pp-glass-face);
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%);
  backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%);
  border: 1px solid var(--pp-glass-edge);
  box-shadow: var(--pp-glass-shadow), var(--pp-glass-gloss);
}
{$s} .pp-hero {
  background: var(--pp-glass-hero) !important;
  -webkit-backdrop-filter: blur(20px) saturate(170%);
  backdrop-filter: blur(20px) saturate(170%);
  border: 1px solid var(--pp-glass-edge);
  box-shadow: var(--pp-glass-shadow), var(--pp-glass-gloss);
}
{$tableClearSel} { background: transparent !important; }
{$tableHeadSel} {
  background: color-mix(in srgb, var(--pp-primary) 16%, transparent) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
{$tableHoverSel} { background: color-mix(in srgb, var(--pp-primary) 14%, transparent) !important; }
{$btn} {
  -webkit-backdrop-filter: blur(12px) saturate(175%);
  backdrop-filter: blur(12px) saturate(175%);
  border: 1px solid var(--pp-glass-edge-soft);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4);
}
{$btnSoft} { background: var(--pp-glass-soft); }
{$s} .ppd-grid-toolbar { gap: 8px; }
{$s} .btn-primary, {$s} .pp-btn-form, {$s} .ppcal-primary, {$s} .ppa-btn-primary, {$s} .pp-prof-save, {$s} .pp-pwd-save, {$s} .pp2fa-act {
  position: relative; overflow: hidden;
  background: var(--pp-primary) !important; color: var(--pp-primary-text) !important;
  border-color: color-mix(in srgb, var(--pp-primary) 60%, transparent) !important;
  box-shadow: var(--pp-glass-shadow), inset 0 1px 0 rgba(255,255,255,.55);
}
{$s} .btn-primary::before, {$s} .pp-btn-form::before, {$s} .ppcal-primary::before, {$s} .ppa-btn-primary::before, {$s} .pp-prof-save::before, {$s} .pp-pwd-save::before, {$s} .pp2fa-act::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 50%;
  background: linear-gradient(180deg, rgba(255,255,255,.45), transparent); pointer-events: none;
}
{$inp} {
  background: var(--pp-glass-soft);
  -webkit-backdrop-filter: blur(10px) saturate(165%);
  backdrop-filter: blur(10px) saturate(165%);
  border: 1px solid var(--pp-glass-edge-soft);
  box-shadow: inset 0 1px 3px rgba(15,23,42,.07), inset 0 1px 0 rgba(255,255,255,.35);
}
/* Floating-label fields (top form rows) hard-set an opaque --pp-surface at a
   higher specificity than .pp-input — so they stayed solid white. Re-frost them
   and make the floated label "notch" a translucent patch, not a white strip. */
{$s} .is-float-outline .pp-input, {$s} .is-float-filled .pp-input {
  background: var(--pp-glass-soft) !important;
  -webkit-backdrop-filter: blur(10px) saturate(165%);
  backdrop-filter: blur(10px) saturate(165%);
  border: 1px solid var(--pp-glass-edge-soft) !important;
  box-shadow: inset 0 1px 3px rgba(15,23,42,.07), inset 0 1px 0 rgba(255,255,255,.35);
}
{$s} .is-float-outline .pp-input:focus ~ label,
{$s} .pp-fld.is-float-outline .pp-input:not(:placeholder-shown) ~ label {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--pp-surface) 40%, transparent) 0%,
    color-mix(in srgb, var(--pp-primary) 20%, transparent) 100%);
  -webkit-backdrop-filter: blur(7px) saturate(170%); backdrop-filter: blur(7px) saturate(170%);
  border-radius: 8px; padding: 0 .45rem;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 2px 7px color-mix(in srgb, var(--pp-primary) 16%, transparent);
}
{$s}.is-dark .is-float-outline .pp-input:focus ~ label,
{$s}.is-dark .pp-fld.is-float-outline .pp-input:not(:placeholder-shown) ~ label {
  box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 7px rgba(0,0,0,.32);
}
{$s} .nav-pills, {$s} .ppd-tabs {
  background: var(--pp-glass-soft); border: 1px solid var(--pp-glass-edge);
  border-radius: var(--pp-radius, 10px); padding: 4px;
  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
}
{$s} .form-switch .form-check-input:checked {
  border-color: transparent;
  box-shadow: inset 0 1px 2px rgba(0,0,0,.12), 0 3px 8px color-mix(in srgb, var(--pp-primary) 45%, transparent);
}
{$s} #pp-calendar-app, {$s} .ppcal-app, {$s} .pp-calapp, {$s} .ppcal-grid-scroll { background: transparent !important; border: 0 !important; box-shadow: none !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
{$s} .ppcal-grid { background: color-mix(in srgb, var(--pp-primary) 12%, transparent) !important; border-radius: 14px; overflow: hidden; }
{$s} .ppcal-cell {
  background: var(--pp-glass-soft) !important;
  -webkit-backdrop-filter: blur(12px) saturate(170%); backdrop-filter: blur(12px) saturate(170%);
  border-color: transparent !important;
}
{$s} .ppcal-cell.is-weekend { background: color-mix(in srgb, var(--pp-primary) 12%, var(--pp-glass-soft)) !important; }
{$s} .ppcal-cell.is-other { opacity: .55; }
/* View switcher (Month/Week/Day/…) — frosted track + primary-tinted active pill. */
{$s} .ppcal-views {
  background: var(--pp-glass-soft) !important;
  border: 1px solid var(--pp-glass-edge) !important;
  -webkit-backdrop-filter: blur(14px) saturate(170%); backdrop-filter: blur(14px) saturate(170%);
  box-shadow: var(--pp-glass-gloss);
}
{$s} .ppcal-vw { color: var(--pp-text); border-radius: 8px; transition: background .14s, color .14s; }
{$s} .ppcal-vw:hover { background: color-mix(in srgb, var(--pp-surface) 40%, transparent); }
{$s} .ppcal-vw.is-active {
  background: color-mix(in srgb, var(--pp-primary) 22%, transparent) !important;
  color: var(--pp-primary) !important;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 7px color-mix(in srgb, var(--pp-primary) 22%, transparent) !important;
}
/* Sidebar list panels (My Calendar / Categories) — light translucent glass,
   not the heavy white card, so the aurora reads through. */
{$s} .ppcal-callist, {$s} .ppcal-cats {
  background: color-mix(in srgb, var(--pp-surface) 26%, transparent) !important;
  border: 1px solid var(--pp-glass-edge-soft) !important;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.32) !important;
  -webkit-backdrop-filter: blur(12px) saturate(150%); backdrop-filter: blur(12px) saturate(150%);
  border-radius: 12px; padding: 6px;
}
{$s} .ppcal-calrow:hover, {$s} .ppcal-catrow:hover {
  background: color-mix(in srgb, var(--pp-primary) 12%, transparent) !important;
}
{$s} .ppd-grid-toolbar { background: transparent !important; border: 0 !important; box-shadow: none !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
{$s} .pp-form-body .pp-form-body, {$s} .ppg-form-section, {$s} .ppg-form-grid, {$s} .pp-lgcell, {$s} .pp-zone12, {$s} .pp-fld {
  background: transparent !important; border: 0 !important; box-shadow: none !important;
  -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
}
/* The outer .pp-form-body IS the glass card — base gives it no padding, so
   fields/actions touched the frosted edge. Inset the content; zero the nested. */
{$s} .pp-form-body { padding: 1.5rem 1.65rem; }
{$s} .pp-form-body .pp-form-body { padding: 0 !important; }
/* ── Liquid-glass edge on the outer form card (direct child of .ppg-form only,
   so the transparent nested grid keeps no rim). A masked gradient stroke paints
   a bright specular rim that fades to an iridescent primary tint mid-edge, plus
   a soft chromatic refraction glow pooled under the card = "liquid glass". ── */
{$liqSel} {
  position: relative;
  border: 0 !important;
  border-radius: calc(var(--pp-radius, 16px) + 6px);
  overflow: visible !important;
  box-shadow:
    var(--pp-glass-shadow),
    0 30px 60px -28px color-mix(in srgb, var(--pp-primary) 30%, transparent),
    inset 0 1px 0 rgba(255,255,255,.6),
    inset 0 -24px 44px rgba(255,255,255,.05);
}
{$liqBefore} {
  content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.5px;
  background: linear-gradient(135deg,
    rgba(255,255,255,.92) 0%,
    rgba(255,255,255,.28) 22%,
    color-mix(in srgb, var(--pp-primary) 34%, transparent) 48%,
    rgba(255,255,255,.12) 70%,
    rgba(255,255,255,.55) 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  pointer-events: none; z-index: 3;
}
{$liqAfter} {
  content: ""; position: absolute; left: 7%; right: 7%; bottom: -15px; height: 30px;
  border-radius: 50%; background: var(--pp-glass-refract);
  filter: blur(17px); opacity: .4; z-index: -1; pointer-events: none;
}
{$liqDarkSel} {
  box-shadow:
    var(--pp-glass-shadow),
    0 34px 64px -30px rgba(0,0,0,.6),
    inset 0 1px 0 rgba(255,255,255,.2),
    inset 0 -24px 44px rgba(255,255,255,.02);
}
{$liqDarkBefore} {
  background: linear-gradient(135deg,
    rgba(255,255,255,.5) 0%,
    rgba(255,255,255,.1) 24%,
    color-mix(in srgb, var(--pp-primary) 42%, transparent) 50%,
    rgba(255,255,255,.05) 72%,
    rgba(255,255,255,.28) 100%);
}
/* Record-card header: base gives it an OPAQUE --pp-surface-soft rectangle —
   that reads stark-white in light AND its square corners poke past the card's
   rounded rim now that the card is overflow:visible. Frost it (translucent
   primary-tinted band) and round the top corners to the card radius. */
{$s} .pp-record-card-h {
  background: linear-gradient(120deg,
    color-mix(in srgb, var(--pp-primary) 24%, transparent),
    color-mix(in srgb, var(--pp-primary) 8%, transparent)) !important;
  border-bottom: 1px solid var(--pp-glass-edge-soft) !important;
  border-radius: calc(var(--pp-radius, 16px) + 6px) calc(var(--pp-radius, 16px) + 6px) 0 0;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
}
{$s} .pp-form-head {
  background: transparent; border: 0; padding: 0 0 .9rem; margin: 0 0 1.15rem;
  border-bottom: 1px solid var(--pp-glass-edge-soft);
}
{$s} .pp-input:focus, {$s} .pp-fld-wrap:focus-within .pp-input, {$s} .form-control:focus, {$s} .form-select:focus {
  outline: none; border-color: color-mix(in srgb, var(--pp-primary) 55%, transparent) !important;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary) 20%, transparent), inset 0 1px 3px rgba(15,23,42,.06);
}
{$s} .pp-form-actions {
  background: transparent !important; border: 0 !important;
  border-top: 1px solid var(--pp-glass-edge-soft) !important; border-radius: 0 !important; box-shadow: none !important;
  -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
  margin: 1.25rem 0 0 !important; padding: 1.1rem .1rem 0 !important;
}
{$s} .pp-master-card, {$s} .pp-detail-grid, {$s} .pp-vsummary { margin-top: 1.4rem; }
{$s} .pp-list-footer {
  border: 0 !important; box-shadow: var(--pp-glass-shadow) !important;
  border-radius: 18px; padding: 12px 22px !important;
}
{$s} .ppd-grid-pagination, {$s} .pp-pg { border-radius: 12px; }
/* List page chrome — pagination pills, filter chips + chip bar, all frosted so
   they match the glass toolbar/table instead of opaque white surfaces. */
{$s} .pp-pg {
  background: var(--pp-glass-soft) !important;
  border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(10px) saturate(160%); backdrop-filter: blur(10px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
}
{$s} .pp-pg:hover { border-color: color-mix(in srgb, var(--pp-primary) 45%, transparent) !important; color: var(--pp-primary) !important; }
{$s} .pp-pg.active {
  background: var(--pp-primary) !important;
  border-color: color-mix(in srgb, var(--pp-primary) 60%, transparent) !important;
  color: var(--pp-primary-text) !important;
  box-shadow: var(--pp-glass-shadow), inset 0 1px 0 rgba(255,255,255,.5);
}
{$s} .pp-pg.gap { background: transparent !important; border: 0 !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; box-shadow: none !important; }
{$s} .pp-chip {
  background: color-mix(in srgb, var(--pp-primary) 20%, transparent) !important;
  -webkit-backdrop-filter: blur(8px) saturate(160%); backdrop-filter: blur(8px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.3);
}
{$s} .pp-filter-chips {
  background: color-mix(in srgb, var(--pp-surface) 22%, transparent) !important;
  border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(10px) saturate(150%); backdrop-filter: blur(10px) saturate(150%);
}
/* ── Admin Area (in-app permissions console) — its ppa-* cards/buttons/tables
   are inline-styled opaque --pp-surface (spec 0,1,0); these :root rules (0,2,0)
   re-skin them as glass. It loads theme.css + sits in the aurora shell. ── */
{$s} .ppa-card, {$s} .ppa-gcard, {$s} .ppa-guide {
  background: var(--pp-glass-face) !important;
  border: 1px solid var(--pp-glass-edge) !important;
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%);
  box-shadow: var(--pp-glass-shadow), var(--pp-glass-gloss) !important;
}
{$s} .ppa-toolbar { background: transparent !important; border-bottom: 1px solid var(--pp-glass-edge-soft) !important; }
{$s} .ppa-btn {
  background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(10px) saturate(160%); backdrop-filter: blur(10px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.35);
}
{$s} .ppa-btn:hover { border-color: color-mix(in srgb, var(--pp-primary) 45%, transparent) !important; background: color-mix(in srgb, var(--pp-primary) 12%, var(--pp-glass-soft)) !important; }
{$s} .ppa-toolbar input[type=text] {
  background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
{$s} .ppa-tabs { border-bottom-color: var(--pp-glass-edge-soft) !important; }
{$s} .ppa-table th { background: color-mix(in srgb, var(--pp-primary) 12%, transparent) !important; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
{$s} .ppa-table tbody tr:hover td { background: color-mix(in srgb, var(--pp-primary) 10%, transparent) !important; }
{$s} .ppa-chip { background: color-mix(in srgb, var(--pp-primary) 14%, transparent) !important; }
{$s} .ppa-matrix thead th { background: color-mix(in srgb, var(--pp-primary) 14%, var(--pp-surface)) !important; }
{$s} .ppa-gitem {
  background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
{$s} .ppa-gitem.is-active, {$s} .ppa-gitem:hover { background: color-mix(in srgb, var(--pp-primary) 16%, transparent) !important; border-color: color-mix(in srgb, var(--pp-primary) 40%, transparent) !important; }
/* ── Audit log + Record Locks (both use ppal-*; in-shell, --pp-* tokens) ── */
{$s} .ppal-scroll {
  background: var(--pp-glass-face) !important;
  border: 1px solid var(--pp-glass-edge) !important;
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%);
  box-shadow: var(--pp-glass-shadow), var(--pp-glass-gloss) !important;
}
{$s} .ppal-table thead th { background: color-mix(in srgb, var(--pp-primary) 12%, transparent) !important; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
{$s} .ppal-table tbody tr:hover { background: color-mix(in srgb, var(--pp-primary) 9%, transparent) !important; }
{$s} .ppal-diff th, {$s} .ppal-diff td { border-color: var(--pp-glass-edge-soft) !important; }
/* ── Two-Factor setup page (pp2fa-*; --pp-* tokens). NOTE: never touch
   .pp2fa-qr (must stay solid #fff to remain scannable). Interactive cards
   (mcard/toggle) are frosted background-ONLY so their is-sel/is-on state
   borders survive. The split-screen login challenge uses different classes
   (.pp2fa-form/.pp2fa-field/.pp2fa-act) and is left to the auth template. ── */
{$s} .pp2fa-setup, {$s} .pp2fa-card {
  background: var(--pp-glass-face) !important;
  border: 1px solid var(--pp-glass-edge) !important;
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%);
  box-shadow: var(--pp-glass-shadow), var(--pp-glass-gloss) !important;
}
{$s} .pp2fa-toggle, {$s} .pp2fa-mcard {
  background: var(--pp-glass-face) !important;
  -webkit-backdrop-filter: blur(14px) saturate(180%); backdrop-filter: blur(14px) saturate(180%);
}
{$s} .pp2fa-key, {$s} .pp2fa-codes code, {$s} .pp2fa-btn.is-ghost { background: var(--pp-glass-soft) !important; }
{$s} .pp2fa-otp input {
  background: var(--pp-glass-soft) !important; border-color: var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
/* ── Profile + Change-password (in-shell, on aurora). Their cards get the liquid
   rim/refraction via the liquid-cards list + frosted face via surfaces; here we glass the
   fields, data rows and requirement panel. CTAs join the primary-gloss group. ── */
{$s} .pp-prof-field input, {$s} .pp-pwd-field input {
  background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
{$s} .pp-prof-row { background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important; }
{$s} .pp-pwd-reqs { background: color-mix(in srgb, var(--pp-surface) 24%, transparent) !important; border: 1px solid var(--pp-glass-edge-soft) !important; }
/* ── 2FA login challenge → single-column app aurora + frosted glass form (form
   gets the liquid rim via the liquid-cards list). Neutralises the split-screen showcase. ── */
{$s} .pp2fa-auth {
  grid-template-columns: 1fr !important; place-items: center;
  background-color: var(--pp-bg) !important;
  background-image:
    radial-gradient(42% 48% at 16% 8%, color-mix(in srgb, var(--pp-primary) 62%, transparent), transparent 60%),
    radial-gradient(38% 44% at 88% 4%, rgba(56,207,255,.5), transparent 58%),
    radial-gradient(48% 54% at 90% 88%, rgba(168,110,255,.5), transparent 60%),
    radial-gradient(42% 48% at 6% 94%, rgba(255,110,180,.42), transparent 58%),
    radial-gradient(60% 66% at 48% 46%, color-mix(in srgb, var(--pp-primary) 26%, transparent), transparent 72%) !important;
}
{$s} .pp2fa-auth .pp2fa-show { display: none !important; }
{$s} .pp2fa-auth .pp2fa-zone { background: transparent !important; }
{$s} .pp2fa-form { color: var(--pp-text) !important; }
{$s} .pp2fa-field input {
  background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); color: var(--pp-text) !important;
}
/* These auth cards carry a VARIANT-SCOPED inline rule (e.g.
   `.pp-profile.is-card .pp-prof-card{background:var(--pp-surface)}`, spec 0,3,0)
   in the page's own late <style>, which beats the general surfaces face
   (`:root .pp-prof-card`, 0,2,0) — so the card stayed opaque white/dark. Force
   the frosted glass face + blur with !important (which beats the non-important
   0,3,0). Also re-point their accent (test1 ships green `--prof/pwd/auth-accent`
   set inline) at `--pp-primary` via !important — !important beats a plain inline
   custom prop — so avatar/badge/links/CTAs all go the app indigo = cohesive glass. */
{$s} .pp-prof-card, {$s} .pp-pwd-card, {$s} .pp2fa-form {
  background: var(--pp-glass-face) !important;
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%) !important;
  backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%) !important;
}
{$s} .pp-profile { --prof-accent: var(--pp-primary) !important; }
{$s} .pp-pwdform { --pwd-accent: var(--pp-primary) !important; }
{$s} .pp2fa-auth { --auth-accent: var(--pp-primary) !important; }
/* ── When the Glass surface is active it must WIN over whichever auth template
   variant is saved (verdant / halo / executive / split / …): force EVERY
   .ppd-auth-page to the app liquid-glass look — aurora page, showcase hidden,
   single-column, frosted form with the liquid rim + refraction, glass fields,
   glossy primary, indigo accent — all !important so the 0,3,0 variant-scoped
   inline rules lose. Turns off automatically when Glass is not selected. ── */
{$s} .ppd-auth-page {
  --auth-accent: var(--pp-primary) !important;
  grid-template-columns: 1fr !important; place-items: center !important;
  background-color: var(--pp-bg) !important;
  background-image:
    radial-gradient(42% 48% at 16% 8%, color-mix(in srgb, var(--pp-primary) 62%, transparent), transparent 60%),
    radial-gradient(38% 44% at 88% 4%, rgba(56,207,255,.5), transparent 58%),
    radial-gradient(48% 54% at 90% 88%, rgba(168,110,255,.5), transparent 60%),
    radial-gradient(42% 48% at 6% 94%, rgba(255,110,180,.42), transparent 58%),
    radial-gradient(60% 66% at 48% 46%, color-mix(in srgb, var(--pp-primary) 26%, transparent), transparent 72%) !important;
}
{$s} .ppd-auth-page::before, {$s} .ppd-auth-page::after { display: none !important; }
{$s} .ppd-auth-page .ppd-auth-showcase { display: none !important; }
{$s} .ppd-auth-page .ppd-auth-card-zone { background: transparent !important; }
{$s} .ppd-auth-page .ppd-auth-form {
  position: relative; border: 0 !important; border-radius: 26px !important; overflow: visible !important;
  background: linear-gradient(140deg, color-mix(in srgb, var(--pp-surface) 30%, transparent) 0%, color-mix(in srgb, var(--pp-surface) 13%, transparent) 55%, color-mix(in srgb, var(--pp-primary) 10%, transparent) 100%) !important;
  color: var(--pp-text) !important;
  -webkit-backdrop-filter: blur(34px) saturate(205%) !important; backdrop-filter: blur(34px) saturate(205%) !important;
  box-shadow: var(--pp-glass-shadow), 0 42px 84px -30px color-mix(in srgb, var(--pp-primary) 34%, transparent), inset 0 1px 0 rgba(255,255,255,.55), inset 0 -30px 60px rgba(255,255,255,.05) !important;
}
{$s} .ppd-auth-page .ppd-auth-form::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1.5px;
  background: linear-gradient(135deg, rgba(255,255,255,.92) 0%, rgba(255,255,255,.28) 22%, color-mix(in srgb, var(--pp-primary) 34%, transparent) 48%, rgba(255,255,255,.12) 70%, rgba(255,255,255,.55) 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
}
{$s} .ppd-auth-page .ppd-auth-form::after {
  content: ""; position: absolute; left: 8%; right: 8%; bottom: -14px; height: 26px; border-radius: 50%;
  background: var(--pp-glass-refract); filter: blur(16px); opacity: .4; z-index: -1; pointer-events: none;
}
{$s} .ppd-auth-page .ppd-auth-form h2, {$s} .ppd-auth-page .ppd-auth-subtitle, {$s} .ppd-auth-page .ppd-auth-field { color: var(--pp-text) !important; }
/* Variants set the secondary text/links/placeholders WHITE for their dark
   showcases — invisible on the light glass card; force readable theme colors. */
{$s} .ppd-auth-page .ppd-auth-subtitle, {$s} .ppd-auth-page .ppd-auth-links, {$s} .ppd-auth-page .ppd-auth-security, {$s} .ppd-auth-page .ppd-auth-options, {$s} .ppd-auth-page .ppd-auth-divider { color: var(--pp-muted) !important; }
{$s} .ppd-auth-page a { color: var(--pp-primary) !important; }
{$s} .ppd-auth-page .ppd-auth-field input::placeholder { color: var(--pp-muted) !important; opacity: 1; }
{$s} .ppd-auth-page .ppd-auth-form > .ppd-auth-brand { display: flex !important; }
{$s} .ppd-auth-page .ppd-auth-field input {
  background: color-mix(in srgb, var(--pp-surface) 20%, transparent) !important; border: 1px solid var(--pp-glass-edge) !important; color: var(--pp-text) !important;
  -webkit-backdrop-filter: blur(10px) saturate(160%); backdrop-filter: blur(10px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 1px 3px rgba(15,23,42,.05);
}
{$s} .ppd-auth-page .ppd-auth-field input:focus {
  border-color: color-mix(in srgb, var(--pp-primary) 55%, transparent) !important;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--pp-primary) 20%, transparent), inset 0 1px 0 rgba(255,255,255,.4) !important;
}
{$s} .ppd-auth-page .ppd-auth-action-primary, {$s} .ppd-auth-page .ppd-auth-action {
  position: relative; overflow: hidden;
  background: var(--pp-primary) !important; color: var(--pp-primary-text) !important;
  box-shadow: var(--pp-glass-shadow), inset 0 1px 0 rgba(255,255,255,.55) !important;
}
{$s} .ppd-auth-page .ppd-auth-action-primary::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 50%;
  background: linear-gradient(180deg, rgba(255,255,255,.45), transparent); pointer-events: none;
}
{$s} .ppd-auth-page .ppd-auth-social button { background: var(--pp-glass-soft) !important; border: 1px solid var(--pp-glass-edge-soft) !important; }
/* Profile split/cover variants use side/main/cover-body instead of .pp-prof-card. */
{$s} .pp-prof-side, {$s} .pp-prof-main, {$s} .pp-prof-cover-body {
  background: var(--pp-glass-face) !important; border: 1px solid var(--pp-glass-edge-soft) !important;
  -webkit-backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%) !important; backdrop-filter: blur(var(--pp-glass-blur)) saturate(190%) !important;
}
/* ── Profile + password-form wrappers ALSO carry scenic variant backgrounds
   (e.g. the verdant green landscape on .pp-profile.is-verdant + its ::before) and
   white/green text. They sit in the app shell (already on the aurora), so make
   the wrapper transparent + kill its scenery pseudo, and force readable
   theme-coloured text / indigo accents — same treatment as .ppd-auth-page. ── */
{$s} .pp-profile, {$s} .pp-pwdform { background: transparent !important; color: var(--pp-text) !important; }
{$s} .pp-profile::before, {$s} .pp-profile::after, {$s} .pp-pwdform::before, {$s} .pp-pwdform::after { display: none !important; }
{$s} .pp-profile .pp-prof-headline h2, {$s} .pp-profile .pp-prof-id strong, {$s} .pp-profile .pp-prof-row strong, {$s} .pp-profile .pp-prof-field input,
{$s} .pp-pwdform .pp-pwd-head h2, {$s} .pp-pwdform .pp-pwd-field input { color: var(--pp-text) !important; }
{$s} .pp-profile .pp-prof-headline p, {$s} .pp-profile .pp-prof-row span, {$s} .pp-profile .pp-prof-field > span, {$s} .pp-profile .pp-prof-field label,
{$s} .pp-pwdform .pp-pwd-head p, {$s} .pp-pwdform .pp-pwd-reqs { color: var(--pp-muted) !important; }
{$s} .pp-profile .pp-prof-field input, {$s} .pp-pwdform .pp-pwd-field input {
  background: color-mix(in srgb, var(--pp-surface) 20%, transparent) !important; border: 1px solid var(--pp-glass-edge) !important;
  -webkit-backdrop-filter: blur(10px) saturate(160%); backdrop-filter: blur(10px) saturate(160%);
}
{$s} .pp-profile .pp-prof-field input::placeholder, {$s} .pp-pwdform .pp-pwd-field input::placeholder { color: var(--pp-muted) !important; opacity: 1; }
{$s} .pp-profile .pp-prof-role { background: color-mix(in srgb, var(--pp-primary) 18%, transparent) !important; color: var(--pp-primary) !important; }
{$s} .pp-profile .pp-prof-link { color: var(--pp-primary) !important; }
{$refract} { position: relative; }
{$refractAfter} {
  content: ""; position: absolute; left: 6%; right: 6%; bottom: -9px; height: 20px;
  border-radius: 50%; background: var(--pp-glass-refract);
  filter: blur(11px); opacity: .5; z-index: -1; pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  {$s} body { animation: pp-aurora-drift 60s ease-in-out infinite alternate; }
}
@keyframes pp-aurora-drift {
  0%   { background-position: 0% 0%, 0% 0%, 0% 0%, 0% 0%, 0% 0%; }
  100% { background-position: 7% 5%, -6% 4%, 5% -5%, -5% -4%, 3% 3%; }
}
@supports not ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  {$surf}, {$btn}, {$inp} { background: var(--pp-surface); }
  {$shellSel} { background: var(--pp-primary) !important; }
  {$tableClearSel} { background: var(--pp-surface) !important; }
}
@media (prefers-reduced-transparency: reduce) {
  {$surf}, {$btn}, {$inp} {
    background: var(--pp-surface) !important;
    -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
  }
  {$shellSel} { background: var(--pp-primary) !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
  {$refractAfter}, {$s} .btn-primary::before, {$s} .pp-btn-form::before { display: none !important; }
  {$s} body { background-image: none; }
}
CSS;
    }

    /** Shell / grid / density CSS variables (topbar, sidebar, logo, panel colours…). */
    private static function shellBlock(string $scope, array $theme): string
    {
        $shell = (array) ($theme['shell'] ?? []);
        $grid = (array) ($theme['grid'] ?? []);
        $pal = static fn (string $n): array => self::SHELL_PALETTES[$n] ?? self::SHELL_PALETTES['navy'];

        $top = $pal($shell['topbar']['theme'] ?? 'navy');
        $side = $pal($shell['sidebar']['theme'] ?? 'navy');
        $logo = $pal($shell['logo']['theme'] ?? ($shell['topbar']['theme'] ?? 'navy'));

        $lines = [
            '--pp-topbar-bg: ' . $top['bg'],
            '--pp-topbar-text: ' . $top['text'],
            '--pp-topbar-accent: ' . $top['accent'],
            '--pp-topbar-h: ' . self::sanitize((string) ($shell['topbar']['height'] ?? '56px')),
            '--pp-sidebar-bg: ' . $side['bg'],
            '--pp-sidebar-text: ' . $side['text'],
            '--pp-sidebar-active: ' . $side['active'],
            '--pp-sidebar-accent: ' . $side['accent'],
            '--pp-sidebar-w: ' . self::sanitize((string) ($shell['sidebar']['width'] ?? '250px')),
            '--pp-logo-bg: ' . $logo['bg'],
            '--pp-logo-text: ' . $logo['text'],
            '--pp-density-pad: ' . (self::DENSITY_PAD[$theme['density'] ?? 'normal'] ?? '10px'),
            // Grid surfaces FOLLOW the theme unless the Grid tab explicitly sets a
            // colour. They used to default to a literal #ffffff, which broke every
            // dark-by-nature theme (Dracula, Midnight, Nord…) in light mode: rows
            // stayed white while --pp-text was light, so the text vanished. Dark
            // mode already had a patch for this; the light side never did.
            '--pp-grid-panel: ' . self::gridSurface($grid['panelColor'] ?? '', $theme),
            '--pp-grid-bg: ' . self::gridSurface($grid['gridColor'] ?? '', $theme),
        ];

        // ── Per-variant navbar options ───────────────────────────────────────
        // Exposed as tokens (not baked into the CSS) so a navbar variant stays
        // tunable per theme and the Style module can drive it live.
        $num = static function (mixed $v, string $fallback): string {
            $v = self::sanitize((string) $v);
            return preg_match('/^\d+(\.\d+)?(px|rem|em|%|vw)$/', $v) ? $v : $fallback;
        };
        $ang = self::sanitize((string) ($shell['hero']['angle'] ?? ''));
        $lines[] = '--pp-boxed-w: '        . $num($shell['boxed']['maxWidth'] ?? '', '1240px');
        $lines[] = '--pp-rail-w: '         . $num($shell['sidebar']['railWidth'] ?? '', '72px');
        $lines[] = '--pp-rail-expanded-w: '. $num($shell['sidebar']['railExpanded'] ?? '', '230px');
        $lines[] = '--pp-hero-angle: '     . (preg_match('/^-?\d{1,3}deg$/', $ang) ? $ang : '135deg');
        $lines[] = '--pp-hero-h: '         . $num($shell['hero']['height'] ?? '', '84px');

        // Shell chrome (hover/active/border/scrollbar) derived FROM the chosen
        // shell palette. A fixed rgba(255,255,255,.08) is invisible on the light
        // or white shells, so the tint direction flips with the background:
        // white veil on a dark shell, black veil on a light one.
        foreach (['topbar' => $top, 'sidebar' => $side] as $part => $p) {
            $bg = (string) $p['bg'];
            // 'primary' resolves to var(--pp-primary) — assume dark chrome there.
            $isDark = str_starts_with($bg, 'var(') ? true : Color::isDark($bg);
            $veil = $isDark ? '#ffffff' : '#000000';
            $lines[] = "--pp-$part-hover: " . Color::alpha($veil, $isDark ? 0.08 : 0.06);
            $lines[] = "--pp-$part-active-bg: " . Color::alpha($veil, $isDark ? 0.14 : 0.10);
            $lines[] = "--pp-$part-border: " . Color::alpha($veil, $isDark ? 0.10 : 0.12);
            $lines[] = "--pp-$part-scrollbar: " . Color::alpha($veil, $isDark ? 0.20 : 0.18);
        }

        return $scope . " {\n  " . implode(";\n  ", $lines) . ";\n}";
    }

    /**
     * @param array<string,string> $tokens  tokens to emit verbatim
     * @param array<string,string> $palette effective palette used for derivation
     */
    /**
     * A Grid-tab surface colour — but never one that contradicts the palette.
     *
     * Every saved theme carries `panelColor/gridColor: #ffffff` as a default,
     * including dark-by-nature themes (Dracula, Midnight, Nord…). Emitting that
     * literally gave white rows under light text, so grid content disappeared.
     * When the chosen colour's lightness disagrees with the theme's surface we
     * defer to the surface; a deliberate colour that matches is kept as-is.
     */
    private static function gridSurface(mixed $value, array $theme): string
    {
        $v = self::sanitize((string) $value);
        if ($v === '') return 'var(--pp-surface)';

        $surface = trim((string) ($theme['tokens']['surface'] ?? self::BASE_FALLBACK['surface']));
        if (Color::parse($v) === null || Color::parse($surface) === null) return $v;

        return Color::isDark($v) === Color::isDark($surface) ? $v : 'var(--pp-surface)';
    }

    private static function block(string $selector, array $tokens, array $palette = []): string
    {
        $lines = [];

        foreach ($tokens as $key => $value) {
            $value = self::sanitize((string) $value);
            if ($value === '') {
                continue;
            }

            $var = self::VARMAP[$key] ?? ('--pp-' . self::kebab((string) $key));
            $lines[] = '  ' . $var . ': ' . $value . ';';

            foreach (self::ALIAS[$key] ?? [] as $alias) {
                $lines[] = '  ' . $alias . ': ' . $value . ';';
            }

            // derive a small radius scale from the single radius token
            if ($key === 'radius' && preg_match('/^(\d+(?:\.\d+)?)px$/', $value, $m)) {
                $n = (float) $m[1];
                $lines[] = '  --pp-radius-sm: ' . round($n * 0.6, 1) . 'px;';
                $lines[] = '  --pp-radius-lg: ' . round($n * 1.6, 1) . 'px;';
            }
        }

        // Derived palette last so it can reference the resolved base colours.
        // Emitted even when the theme defined nothing, so a bare theme is still
        // complete — components never need a hardcoded fallback.
        if ($palette !== []) {
            foreach (self::derive($palette) as $var => $value) {
                $lines[] = '  ' . $var . ': ' . self::sanitize($value) . ';';
            }
        }

        if (!$lines) {
            return '';
        }

        return $selector . " {\n" . implode("\n", $lines) . "\n}";
    }

    /** Strip characters that could break out of a CSS declaration (user-defined themes). */
    private static function sanitize(string $value): string
    {
        return trim(str_replace(['{', '}', ';', '<', '>', '@import', 'expression('], '', $value));
    }

    private static function kebab(string $key): string
    {
        return strtolower((string) preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $key));
    }
}
