<?php

/**
 * OpsIQ — Public Help Center
 * Phase 3.2
 *
 * Routes:
 *  /help.php               — category index
 *  /help.php?cat=slug      — category article list
 *  /help.php?article=slug  — article view
 *  /help.php?q=term        — search results
 *  /help.php?sitemap=1     — sitemap.xml output
 */

declare(strict_types=0);

/* Public full-help embeds fetch this page from customer domains without cookies. */
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Accept, X-Requested-With');
header('Access-Control-Max-Age: 86400');
if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'OPTIONS') {
    http_response_code(204);
    exit;
}

/* Bootstrap OpsIQ — load config + autoloader only (no admin session) */
$_opsiqRoot = __DIR__;
if (!file_exists($_opsiqRoot . '/vendor/autoload.php')) {
    http_response_code(503); exit('OpsIQ not installed.');
}
require_once $_opsiqRoot . '/vendor/autoload.php';

/* PHASE_HELP_HOUSING_HOST — refuse the wildcard PARENT host before anything else.
 *
 * `help.opsiqai.com` houses each tenant's `<name>.help.opsiqai.com` and belongs to
 * no workspace itself, so it has no help center to serve. This guard must live
 * HERE as well as in index.php: `/hc/index.php` and `/help/index.php` are REAL
 * directories, so the web server hands them straight to this file and the root
 * dispatcher never runs — which is why `help.opsiqai.com/hc` still served the
 * MAIN install's help center after index.php was guarded. Runs before the tenant
 * bootstrap: with no workspace to resolve, there is nothing to look up.
 * A tenant's own subdomain never matches (exact host match only). */
$__housingGuard = $_opsiqRoot . '/opsiq/opsiq.help_housing_host.php';
if (is_file($__housingGuard)) {
    require_once $__housingGuard;
    if (opsiq_help_is_housing_host()) opsiq_help_housing_host_block();
    /* PHASE_HELP_HOST_404 — an UNCLAIMED `*.help.opsiqai.com` name owns no
     * workspace, so nothing may render on it. Needed here as well as in index.php
     * because `/hc/index.php` and `/help/index.php` are real directory shims the
     * web server hands straight to this file — an unmapped host's /hc still served
     * a help center with the dispatcher guarded. */
    if (opsiq_help_host_is_unclaimed()) opsiq_help_host_404_block();
    /* PHASE_HELP_HOST_OWNERSHIP — `/hc` is the PORTAL's own help center. On a host
     * the HELP CENTER allocated, the portal has no presence, so /hc must 404
     * rather than render this page at a second address the portal never created.
     * Must run here as well as in index.php: `hc/index.php` is a real directory
     * shim that hands straight to this file, so the dispatcher never sees it. */
    opsiq_help_block_portal_path_on_help_host();
}

/* PHASE_HC_OWN_DOMAIN_SEO — is THIS REQUEST the PORTAL's /hc surface?
 *
 * /help and /hc are the SAME FILE, so every surface-specific behaviour below needs
 * a surface test. The two own-domain 301s further down move the STANDALONE help
 * center to its configured address, and neither may move /hc: that is the PORTAL's
 * own help center, it lives at the portal's address, and the portal has its own
 * "Your own domain" field (opsiq_portal_domains.surface='portal') for moving it.
 *
 * TESTED BY PATH, NOT BY HOST. $__isPortalHelpHost looks like the right flag and is
 * not: it is true for ANY host in the tenant namespace (`<name>.opsiq.help` and the
 * legacy `<name>.help.opsiqai.com` alike — see opsiq.tenant_suffix.php), including a
 * cloud tenant's help-OWNED one, where /help IS the standalone help center and its
 * 301 to a configured own domain is the whole point of the feature. Only the PATH
 * separates the two surfaces on a shared host, so only the path is asked.
 *
 * A help-owned host serves the help center at its ROOT (path ''), which is not the
 * /hc segment and so redirects normally — correct, and /hc is already 404 there. */
$__isPortalHcPath = false;
if (function_exists('opsiq_help_request_path') && function_exists('opsiq_help_portal_help_path')) {
    $__hcSeg  = opsiq_help_portal_help_path();
    $__hcPath = opsiq_help_request_path();
    $__isPortalHcPath = ($__hcPath === $__hcSeg || $__hcPath === $__hcSeg . '.php'
        || strpos($__hcPath, $__hcSeg . '/') === 0);
    unset($__hcSeg, $__hcPath);
}

/* PHASE_CLOUD_PUBLIC_TENANT — resolve the CLOUD TENANT before touching the DB.
 *
 * Every cloud tenant has its OWN database. help.php used to bootstrap straight to
 * opsiq.standalone_db.php, which connects to the MAIN install's database and nothing
 * else — no host check, no tenant, no cookie. So on cloud.opsiqai.com (and on every
 * tenant domain) the help center served the main install's articles and settings, and
 * EVERY workspace inside a tenant showed the same main-install content: $_siteKey picks
 * a workspace WITHIN a database, so it can never reach a tenant whose data lives in a
 * different one.
 *
 * bootstrap/init.php already does this resolution properly for widget.php: tenant from
 * the ?cloud= token, the opsiq-cloud cookie, or (for anonymous public requests that
 * carry no cookie) the public site_key scanned across tenant DBs. It connects Capsule to
 * the tenant DB and sets $GLOBALS['_opsiq_capsule_booted'], which standalone_db.php now
 * honours instead of clobbering. Self-hosted installs are unaffected: init.php gates all
 * cloud handling on $opsiqHasCloudEngine and falls through to the same main-install
 * config it always used.
 *
 * OPSIQ_EMBED_NO_SESSION: this is a public page and must not auto-start a session (same
 * reason widget.php sets it); init.php still lazy-resumes an existing same-origin one. */
if (!defined('OPSIQ_EMBED_NO_SESSION')) define('OPSIQ_EMBED_NO_SESSION', true);
/* This is a public HTML page: an unresolved cloud tenant must 404, never 302 to /login
 * (an anonymous reader has no cloud session) and never fall through to the main config. */
if (!defined('OPSIQ_PUBLIC_HTML_RESPONSE')) define('OPSIQ_PUBLIC_HTML_RESPONSE', true);
/* The slug is re-parsed properly further down (with the $_siteKey chain), but the tenant
 * must be resolved BEFORE any DB is opened, and on a public request the slug is the only
 * identity we have — so it has to be read here, ahead of the bootstrap. Same source as
 * the canonical parse below: PATH_INFO's first segment, or ?hslug=. */
if (!defined('OPSIQ_PUBLIC_HELP_SLUG')) {
    $__bootPath = trim((string)($_SERVER['PATH_INFO'] ?? ''), '/');
    $__bootSeg  = $__bootPath !== '' ? explode('/', $__bootPath)[0] : '';
    define('OPSIQ_PUBLIC_HELP_SLUG', preg_replace('/[^a-z0-9-]/', '', strtolower(
        $__bootSeg !== '' ? $__bootSeg : (string)($_GET['hslug'] ?? '')
    )));
    unset($__bootPath, $__bootSeg);
}
if (is_file($_opsiqRoot . '/bootstrap/init.php')) {
    try { require_once $_opsiqRoot . '/bootstrap/init.php'; } catch (\Throwable $e) {
        error_log('[opsiq][help] tenant bootstrap failed: ' . $e->getMessage());
    }
}

/* Load Capsule / DB config */
if (file_exists($_opsiqRoot . '/opsiq/opsiq_bootstrap.php')) {
    require_once $_opsiqRoot . '/opsiq/opsiq_bootstrap.php';
} elseif (file_exists($_opsiqRoot . '/opsiq/opsiq.php')) {
    /* Minimal bootstrap — just DB */
    if (!defined('OPSIQ_LOADED')) {
        define('OPSIQ_LOADED', true);
        define('OPSIQ_HELP_PUBLIC', true);
    }
    /* We do NOT include the full opsiq.php (it renders admin UI); instead we
     * rely on the autoloader having already wired Capsule via composer.
     * Platform connectors bridge the DB connection; for standalone installs
     * we load the standalone DB bootstrap. */
    $__standaloneDb = $_opsiqRoot . '/opsiq/opsiq.standalone_db.php';
    if (file_exists($__standaloneDb)) {
        require_once $__standaloneDb;
    }
}

if (!class_exists('\\OpsIQ\\Kb\\HelpCenter')) {
    http_response_code(503); exit('Help Center not available.');
}

use OpsIQ\Kb\HelpCenter;

/* ── Public SEO metadata helpers ─────────────────────────────────────────── */
if (!function_exists('opsiq_h')) {
    function opsiq_h($value): string {
        return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
    }
}
if (!function_exists('opsiq_help_fwd_host')) {
    /* PHASE_HELP_PROXY — the public host as seen by the VISITOR. When this help
     * center is served through a customer's own web address via a reverse proxy,
     * the edge does not preserve Host (it targets opsiqai.com) but forwards the
     * real public host in X-Forwarded-Host. Return the first hop, sanitised to
     * host characters only; empty when there is no proxy in front. */
    function opsiq_help_fwd_host(): string {
        static $memo = null;
        /* HELP_PROXY_CANONICAL_LOOP — a workspace can predate the generated
         * help-host registry and therefore have an authoritative custom_domain
         * while help_hosts.php is empty. Once the workspace/settings layer has
         * proved an exact match it publishes this request-local override. Check
         * it before the memo so an earlier conservative miss cannot pin the
         * whole render to the origin host. */
        $runtime = strtolower(trim((string)($GLOBALS['_opsiq_help_runtime_fwd_host'] ?? '')));
        $runtime = (string)preg_replace('/:\d+$/', '', $runtime);
        if ($runtime !== '') return $runtime;
        if ($memo !== null) return $memo;
        $h = trim((string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''));
        if ($h === '') return $memo = '';
        $h = trim(explode(',', $h)[0]);
        $h = strtolower((string)preg_replace('~[^A-Za-z0-9.\-:]~', '', $h));
        $bare = (string)preg_replace('/:\d+$/', '', $h);
        /* AUDIT 2026-09-07 (lanes 02/12/17) — the header is CLIENT-WRITABLE and this
         * value drives every in-page link, the canonical, og:url, the sitemap
         * <loc> and robots' Sitemap: line, on a page with no Cache-Control. One
         * curl rewrote a tenant's whole KB sitemap to an attacker origin.
         * index.php / widget.php already honour it ONLY on an exact host-map hit
         * (the forwarded-customer-host discipline); this is the outlier. Accept
         * the forwarded host only when it is a key in a generated host map. */
        $root = dirname(__FILE__);
        foreach (['/opsiq/cache/help_hosts.php', '/opsiq/cache/portal_hosts.php', '/opsiq/cache/survey_hosts.php', '/opsiq/cache/store_hosts.php'] as $rel) {
            $map = is_file($root . $rel) ? @include $root . $rel : null;
            if (is_array($map) && ($bare !== '' && isset($map[$bare]))) return $memo = $h;
        }
        return $memo = '';
    }
}
if (!function_exists('opsiq_public_base_url')) {
    function opsiq_public_base_url(): string {
        $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
            || (($_SERVER['SERVER_PORT'] ?? null) == 443)
            || (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https');
        $scheme = $https ? 'https' : 'http';
        /* Prefer the forwarded public host so canonical / sitemap / OG URLs name
         * the customer's own domain when proxied, and fall back to Host otherwise. */
        $host = opsiq_help_fwd_host();
        if ($host === '') $host = (string)($_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? 'opsiqai.com'));
        return $scheme . '://' . $host;
    }
}
if (!function_exists('hc_asset_url')) {
    /* PHASE_HELP_PROXY — root-relative asset paths (/opsiq/uploads/…, the vote
     * endpoint) live OUTSIDE the proxied /help prefix, so a customer's /help-only
     * proxy 404s them. When proxied, rewrite them ABSOLUTE to the origin so the
     * browser loads them straight from opsiqai.com (an <img> is not subject to
     * CORS). When NOT proxied ($__assetBase empty) the path is returned untouched
     * — direct access stays byte-for-byte identical. Absolute / scheme-relative /
     * data: URLs are always left as-is. */
    function hc_asset_url($u): string {
        $u = (string)$u;
        if ($u === '') return '';
        global $__assetBase;
        if (empty($__assetBase)) return $u;
        if (preg_match('~^(?:https?:)?//~i', $u) || strncmp($u, 'data:', 5) === 0) return $u;
        if (isset($u[0]) && $u[0] === '/') return $__assetBase . $u;
        return $u;
    }
}
if (!function_exists('hc_versioned_asset_url')) {
    /**
     * Build a cache-safe URL for a Help Center-owned static asset.
     *
     * `filemtime()` has only one-second resolution. During a release two writes can
     * therefore produce different bytes with the same `?v=` value, after which the
     * CDN is allowed to serve the first copy for its full immutable cache lifetime.
     * A short content digest makes the URL change whenever the bytes change. The
     * per-request memo keeps repeated references to the same asset cheap.
     */
    function hc_versioned_asset_url(string $root, string $relative): string {
        static $memo = [];
        $relative = '/' . ltrim($relative, '/');
        $file = rtrim($root, '/') . $relative;
        if (!isset($memo[$file])) {
            $digest = is_file($file) ? @hash_file('sha256', $file) : false;
            $memo[$file] = is_string($digest) && $digest !== ''
                ? substr($digest, 0, 16)
                : 'missing';
        }
        return hc_asset_url($relative . '?v=' . $memo[$file]);
    }
}
if (!function_exists('hc_img_dims')) {
    /**
     * PHASE_HC_SEO_2026-08-27 — `width`/`height` for a LOCALLY stored image.
     *
     * Without them the browser cannot reserve the box before the file arrives, so the
     * header reflows the moment a logo loads — a layout shift on every page of every
     * workspace, and the one Core Web Vital a help center can actually fail on its own
     * chrome. Measured before this existed: both logo <img> tags carried no dimensions.
     *
     * The values must be the image's REAL intrinsic size, not the CSS box: the browser
     * uses them only for the aspect ratio, and `max-height:40px` in the sheet still
     * decides how big it is drawn. Guessing would reserve the wrong shape and trade one
     * shift for another.
     *
     * Only a root-relative path under this install is measured — a remote logo would
     * mean an HTTP round trip inside a page render, which is a far worse trade than the
     * shift it would prevent. Results are memoised per request because the light and
     * dark logos are emitted on every page and getimagesize() opens the file.
     */
    function hc_img_dims($url): string {
        static $seen = [];
        $url = trim((string)$url);
        if ($url === '' || $url[0] !== '/' || strncmp($url, '//', 2) === 0) return '';
        if (array_key_exists($url, $seen)) return $seen[$url];

        $seen[$url] = '';
        $root = (string)($GLOBALS['_opsiqRoot'] ?? '');
        if ($root === '') return '';
        /* The query string is a cache-buster, not part of the path. */
        $path = realpath($root . '/' . ltrim(explode('?', $url, 2)[0], '/'));
        /* realpath resolves ../ so a traversal in a stored setting cannot read outside
         * the install — this only ever measures files this install serves anyway. */
        if ($path === false || strncmp($path, realpath($root) ?: $root, strlen(realpath($root) ?: $root)) !== 0) return '';
        if (!is_file($path) || !function_exists('getimagesize')) return '';

        $sz = @getimagesize($path);
        if (!is_array($sz) || empty($sz[0]) || empty($sz[1])) return '';

        return $seen[$url] = ' width="' . (int)$sz[0] . '" height="' . (int)$sz[1] . '"';
    }
}
if (!function_exists('hc_lang_flag_img')) {
    /* The country flag for a locale, as a ready <img>, or '' when it has none.
     *
     * File scope on purpose: BOTH pickers need it — the help centre's own nav and the copy
     * injected into the portal nav when the two surfaces share chrome — and those live in
     * different scopes, so a closure would have to be built twice and drift.
     *
     * Real SVG files, not the emoji opsiq_hc_locale_badge() returns: Windows ships no flag
     * emoji at all, so 🇬🇧 draws as the letters "GB" and the trigger read "GB GB". Same
     * imported set the portal uses, so both surfaces label a language with the same flag. */
    function hc_lang_flag_img(string $code): string {
        if (!function_exists('opsiq_portal_locale_flag_url')) {
            $f = __DIR__ . '/opsiq/opsiq.portal_flags.php';
            if (is_file($f)) { try { require_once $f; } catch (\Throwable $e) {} }
        }
        $u = function_exists('opsiq_portal_locale_flag_url') ? opsiq_portal_locale_flag_url($code) : '';
        if ($u === '') return '';
        /* ⚠ hc_asset_url — /opsiq/assets/flags/ sits OUTSIDE the proxied /help prefix, so on a
         * customer's own domain a root-relative src 404s and the picker loses every flag. */
        return '<img class="hc-lang-flag" src="' . hc_esc(hc_asset_url($u)) . '" alt="" width="18" height="14" decoding="async">';
    }
}
if (!function_exists('opsiq_public_canonical_url')) {
    function opsiq_public_canonical_url(array $params = []): string {
        $path = strtok((string)($_SERVER['REQUEST_URI'] ?? '/help.php'), '?');
        $url = opsiq_public_base_url() . ($path ?: '/help.php');
        $clean = [];
        foreach ($params as $key => $value) {
            if ($value === null || $value === '') {
                continue;
            }
            $clean[$key] = (string)$value;
        }
        if (!empty($clean)) {
            $url .= '?' . http_build_query($clean);
        }
        return $url;
    }
}
if (!function_exists('opsiq_json_pretty')) {
    function opsiq_json_pretty(array $data): string {
        return (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }
}

/* ── Workspace resolution ────────────────────────────────────────────────────
 * The Help Center belongs to a Self / Self-Group workspace (a connected
 * domain) or the self-install domain. This public page has no admin session,
 * so resolve the workspace from the REQUEST, in priority order:
 *   0. PHASE_HELP_SLUG — /help/<slug> path (?hslug=) → the workspace's unique
 *      slug. This is how CLOUD workspaces (all on the shared OpsIQ URL, no own
 *      domain) get a distinct help center; the rewrite maps the path to hslug.
 *   1. explicit ?site_key= / ?site= param
 *   2. the domain this page is served on (opsiq_remote_sites.domain)
 *   3. the install's default workspace (self-install domain)
 * Then bind it for the request so all per-site reads (settings, categories,
 * articles) scope to the right Self/Group. */
$_siteKey = '';
if (file_exists($_opsiqRoot . '/opsiq/opsiq.help_slug.php')) {
    require_once $_opsiqRoot . '/opsiq/opsiq.help_slug.php';
}
/* The per-workspace path is /help.php/<slug> (PATH_INFO) — pretty-URL rewrites
 * are not applied on this server, but PATH_INFO works everywhere with no config.
 * Also accept ?hslug= as a fallback. */
$__pathInfo = trim((string)($_SERVER['PATH_INFO'] ?? ''), '/');
$__pathSlug = $__pathInfo !== '' ? explode('/', $__pathInfo)[0] : '';
$__hslug = preg_replace('/[^a-z0-9-]/', '', strtolower($__pathSlug !== '' ? $__pathSlug : (string)($_GET['hslug'] ?? '')));
if ($__hslug !== '' && function_exists('opsiq_help_resolve_slug')) {
    $_siteKey = opsiq_help_resolve_slug($__hslug);
}
$__reqKey = preg_replace('/[^A-Za-z0-9_-]/', '', (string)($_GET['site_key'] ?? $_GET['site'] ?? ''));
if ($_siteKey === '' && $__reqKey !== '') {
    $_siteKey = $__reqKey;
}
/* PHASE_PORTAL_PROXY_SEO — a PROXIED "use my own domain" host carries the
 * visitor's branded address only in X-Forwarded-Host; `Host` is pinned to the
 * INSTALL host by every proxy template. So for each path the proxy passes
 * straight through — /sitemap.xml and /robots.txt among them — every host
 * lookup below sees the install host and lands on the MAIN install's workspace.
 *
 * Those two files never even reached PHP: the .htaccess rule that routes them
 * here was keyed on Host too, so the web server served the static apex files and
 * published OpsIQ MARKETING URLs under the tenant's own domain. That rule now
 * also routes on the forwarded host, and the workspace has to follow it — else
 * this file would answer the tenant's address with someone else's help center,
 * trading a marketing leak for a cross-tenant one.
 *
 * Verified against the SAME generated maps that make a host a customer surface
 * at all, so a forged header buys nothing: an unmapped value is not a customer
 * host and gets exactly the static apex file it would have got before. Ranks
 * below the explicit slug/param (an address never outranks a request that names
 * its workspace outright) and above the Host lookup, which is the proxy's host,
 * not the visitor's. */
$__fwdSeoReq   = isset($_GET['sitemap']) || isset($_GET['robots']);
$__fwdCustHost = function_exists('opsiq_help_forwarded_customer_host')
    ? opsiq_help_forwarded_customer_host()
    : '';
$__fwdSurface  = '';
if ($__fwdCustHost !== '') {
    foreach (['help' => '/opsiq/cache/help_hosts.php', 'portal' => '/opsiq/cache/portal_hosts.php'] as $__fSurf => $__fRel) {
        $__fFile = $_opsiqRoot . $__fRel;
        if (!is_file($__fFile)) continue;
        $__fMap = @include $__fFile;
        if (is_array($__fMap) && !empty($__fMap[$__fwdCustHost])) {
            $__fwdSurface = $__fSurf;
            if ($_siteKey === '') $_siteKey = (string)$__fMap[$__fwdCustHost];
            break;
        }
    }
    unset($__fSurf, $__fRel, $__fFile, $__fMap);
}
/* A tenant-pinned answer must never be cached under the SHARED install URL. The
 * proxy fetches `<install>/sitemap.xml`, and the CDN in front of that origin keys
 * on the URL alone — X-Forwarded-Host is not part of any cache key — so one
 * tenant's sitemap stored there would be replayed to the marketing site and to
 * every other tenant from the same entry. Keeping a per-visitor answer out of a
 * shared-key cache is exactly what no-store is for; these two files are fetched
 * by crawlers, not by visitors, so there is nothing to gain by caching them. */
if ($__fwdSeoReq && $__fwdSurface !== '' && !headers_sent()) {
    header('Cache-Control: private, no-store, max-age=0');
}

/* An UNVERIFIED forwarded host reached the SEO rewrite above: replay the static
 * apex file byte for byte. The rewrite has already consumed the request, so
 * falling through would hand a forger the default workspace's sitemap, and a
 * 404 here would break /sitemap.xml for the marketing site itself.
 *
 * The namespace test is written so a MISSING helper still replays. Spelled the
 * other way round — `function_exists(...) && !...()` — the whole guard would
 * switch itself off exactly when the file it depends on failed to load, and the
 * fall-through it was written to prevent is the leak. */
/* PHASE_HC_SEO_2026-08-27 — REMEMBERED, not answered here.
 *
 * This used to replay the apex file and exit on the spot. That is the right answer for
 * a host that never resolves to a workspace, and the wrong one for every host that
 * does: the workspace is resolved BELOW this line, from the real HTTP_HOST, so a
 * customer's own help-center domain exited here and was handed OpsIQ's MARKETING
 * sitemap. Measured live on a customer host: /help.php?sitemap=1 returned the 51 KB
 * apex file with `<loc>https://opsiqai.com</loc>` throughout, on their domain.
 *
 * The decision moves to the two SEO handlers further down, which know whether a
 * workspace resolved. Nothing about the forgery guard changes: a forged
 * X-Forwarded-Host still cannot NAME a workspace — only the generated host map or the
 * real HTTP_HOST can — so when nothing resolves the apex file is still what goes out,
 * and the `private, no-store` header set just above still keeps a tenant-pinned answer
 * out of the shared-URL CDN cache. */
$__seoReplayApex = ($__fwdSeoReq && $__fwdCustHost === ''
    && trim((string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? '')) !== ''
    && !(function_exists('opsiq_help_host_is_help_namespace')
         && opsiq_help_host_is_help_namespace()));

if ($_siteKey === '' && class_exists('\\Illuminate\\Database\\Capsule\\Manager')) {
    $__host = strtolower(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? '')));
    $__bare = preg_replace('/^www\./', '', $__host);
    if ($__bare !== '') {
        try {
            if (\Illuminate\Database\Capsule\Manager::schema()->hasTable('opsiq_remote_sites')) {
                $__row = \Illuminate\Database\Capsule\Manager::table('opsiq_remote_sites')
                    ->where(function ($q) use ($__bare) {
                        $q->where('domain', $__bare)
                          ->orWhere('domain', 'www.' . $__bare)
                          ->orWhere('domain', 'https://' . $__bare)
                          ->orWhere('domain', 'http://' . $__bare)
                          ->orWhere('domain', 'like', '%' . $__bare . '%');
                    })
                    ->where(function ($q) { $q->where('active', 1)->orWhereNull('active'); })
                    ->first();
                if ($__row && !empty($__row->site_key)) $_siteKey = (string)$__row->site_key;
            }
        } catch (\Throwable $e) {}
    }
}
/* PHASE_PORTAL_P7.5 — served on a BRANDED PORTAL HOST (the /help dispatcher, or
 * /help.php hit directly on *.help.opsiqai.com / a custom portal domain). Being
 * on that host is itself an explicit KB entry point, and it pins the workspace:
 *   - custom portal domain → the owning workspace (portal_hosts cache)
 *   - subdomain / cloud tenant → the install's default workspace. */
$__phHost = strtolower(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? '')));
/* PHASE_TENANT_SUFFIX — namespace test via the ONE suffix source (opsiq.tenant_suffix.php),
 * so <name>.opsiq.help behaves exactly like <name>.help.opsiqai.com. Regex = fallback only. */
$__isPortalHelpHost = defined('OPSIQ_PORTAL_HELP_HOST')
    || (function_exists('opsiq_tenant_host_in_namespace')
        ? opsiq_tenant_host_in_namespace($__phHost)
        : (bool)preg_match('/(^|\.)help\.opsiqai\.com$/', $__phHost));
/* PHASE_PORTAL_SP5 — the host map now carries per-WORKSPACE subdomains as well
 * as custom domains, and /hc is often served via the hc/ directory shim (no
 * index.php dispatcher), so ALWAYS consult it: an exact host binding pins the
 * workspace; unmapped *.help hosts still fall back to the default workspace. */
{
    $__phc = $_opsiqRoot . '/opsiq/cache/portal_hosts.php';
    if (is_file($__phc)) {
        $__pm = @include $__phc;
        if (is_array($__pm) && !empty($__pm[$__phHost])) {
            $__isPortalHelpHost = true;
            if ($_siteKey === '') $_siteKey = (string)$__pm[$__phHost];
        }
    }
}
if ($_siteKey === '' && class_exists('\\OpsIQ\\Sites\\Site')) {
    try { $_siteKey = (string)(\OpsIQ\Sites\Site::defaultSiteKey() ?? ''); } catch (\Throwable $e) {}
}
/* Bind for the request (in-process cache only — no session) so SiteScope
 * reads resolve to this workspace. */
if ($_siteKey !== '' && class_exists('\\OpsIQ\\Sites\\Site')) {
    try { \OpsIQ\Sites\Site::bindForRequest($_siteKey); } catch (\Throwable $e) {}
}

/* Enforce Custom Domains on the public request, not only while saving the
 * setting. The workspace's included Help Center/Portal URLs remain available
 * after downgrade; only an exact configured own-domain request is withdrawn. */
if ($_siteKey !== '' && class_exists('\\OpsIQ\\License\\PlanGate')
    && \OpsIQ\License\PlanGate::planIsResolved()) {
    $__effectiveHelpHost = $__fwdCustHost !== '' ? $__fwdCustHost : $__phHost;
    $__configuredHelpCustom = '';
    try {
        if ($__isPortalHcPath) {
            if (!function_exists('opsiq_workspace_custom_domain')) {
                $__portalDomainsFile = $_opsiqRoot . '/opsiq/opsiq.portal_domains.php';
                if (is_file($__portalDomainsFile)) require_once $__portalDomainsFile;
            }
            if (function_exists('opsiq_workspace_custom_domain')) {
                $__configuredHelpCustom = strtolower(trim((string)opsiq_workspace_custom_domain($_siteKey)));
            }
        } else {
            $__helpSettingsForDomain = \OpsIQ\Kb\HelpCenter::getSettings($_siteKey);
            $__configuredHelpCustom = strtolower(trim((string)($__helpSettingsForDomain['custom_domain'] ?? '')));
            $__configuredHelpCustom = (string)preg_replace('~^https?://~', '', $__configuredHelpCustom);
            $__configuredHelpCustom = trim((string)explode('/', $__configuredHelpCustom)[0]);
            $__configuredHelpCustom = preg_replace('/:\d+$/', '', $__configuredHelpCustom);
        }
    } catch (\Throwable $e) { $__configuredHelpCustom = ''; }
    if ($__configuredHelpCustom !== '' && $__effectiveHelpHost !== ''
        && hash_equals($__configuredHelpCustom, $__effectiveHelpHost)
        && !\OpsIQ\License\PlanGate::feature('custom_domains')) {
        http_response_code(404);
        header('Content-Type: text/html; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Robots-Tag: noindex');
        exit('<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>Help Center not found</title></head><body><h1>Help Center not found</h1></body></html>');
    }
}

/* PHASE_SUBDOMAIN_GATE — on CLOUD, the workspace subdomain (this covers /help,
 * /hc AND the subdomain root's help paths — they all render through this file)
 * serves nothing until the workspace's own website domain is VERIFIED. Same rule
 * the portal root already enforces. Fails safe: only a POSITIVELY-confirmed
 * 'uncovered' blocks — 'unknown' (self-hosted, no tenant creds, or a transient
 * core-DB error) always serves, so a hiccup can never dark-out live help centers. */
if ($_siteKey !== '' && function_exists('opsiq_tenant_host_in_namespace')
    && opsiq_tenant_host_in_namespace($__phHost)) {
    try {
        if (!function_exists('opsiq_cloud_domain_coverage')
            && is_file($_opsiqRoot . '/opsiq/opsiq.domain_verify_client.php')) {
            require_once $_opsiqRoot . '/opsiq/opsiq.domain_verify_client.php';
        }
        /* Token from the const OR the resolved tenant cfg — NOT
         * opsiq_cloud_domain_verify_active(): the tenant CONSTANTS are not reliably
         * defined on public embed/render paths (observed flapping), while init.php
         * always hands the resolved config to $GLOBALS['cfg']. With no token at all
         * (self-hosted), coverage() returns 'unknown' and the gate stays open. */
        if (function_exists('opsiq_cloud_domain_coverage')) {
            $__gateDomain = '';
            try {
                $__gateDomain = (string)\Illuminate\Database\Capsule\Manager::table('opsiq_remote_sites')
                    ->where('site_key', $_siteKey)->value('domain');
            } catch (\Throwable $e) {}
            if ($__gateDomain !== '' && opsiq_cloud_domain_coverage($__gateDomain) === 'uncovered') {
                http_response_code(503);
                header('Content-Type: text/html; charset=utf-8');
                header('Retry-After: 3600');
                header('X-Content-Type-Options: nosniff');
                exit('<!doctype html><html lang="en"><head><meta charset="utf-8">'
                   . '<meta name="viewport" content="width=device-width,initial-scale=1">'
                   . '<meta name="robots" content="noindex"><title>Not available yet</title></head>'
                   . '<body style="margin:0;background:#f8fafc;">'
                   . '<div style="max-width:460px;margin:16vh auto;text-align:center;padding:34px 26px;'
                   . 'background:#fff;border:1px solid #e6e8ee;border-radius:18px;'
                   . 'box-shadow:0 20px 48px -28px rgba(15,23,42,.35);'
                   . 'font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif">'
                   . '<div style="color:#0f172a;margin-bottom:12px"><svg viewBox="0 0 24 24" width="42" height="42" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><line x1="4.93" y1="4.93" x2="9.17" y2="9.17"/><line x1="14.83" y1="14.83" x2="19.07" y2="19.07"/><line x1="14.83" y1="9.17" x2="19.07" y2="4.93"/><line x1="4.93" y1="19.07" x2="9.17" y2="14.83"/></svg></div>'
                   . '<h1 style="font-size:19px;font-weight:800;color:#0f172a;margin:0 0 8px">This page isn&rsquo;t available yet</h1>'
                   . '<p style="font-size:14px;line-height:1.6;color:#64748b;margin:0">Please check back soon &mdash; it hasn&rsquo;t been set up at this address yet.</p>'
                   . '</div></body></html>');
            }
        }
    } catch (\Throwable $e) { /* never let the gate itself break the page */ }
}

/* PHASE_CLOUD_HELP_SUBDOMAIN_ONLY — on CLOUD, a workspace's help center lives at
 * its OWN subdomain, and the shared-host slug URL is not an address, only a
 * signpost to it.
 *
 * WHY the shared `cloud.opsiqai.com/help/<slug>` address cannot stay:
 * `help_slug` is a plain nullable column inside EACH TENANT's own database, and
 * opsiq_help_assign_slug()'s "ensure uniqueness across all sites" check only ever
 * queries the CONNECTED database — so uniqueness holds within one tenant and is
 * impossible to enforce between them. The resolver
 * (opsiq_bootstrap_help_slug_site_key) then globs tenant configs and returns the
 * FIRST database whose help_slug matches. Two tenants both choosing `support` and
 * the winner is filesystem order. At scale that is one tenant's help center
 * served to another's customers — a cross-tenant leak built into the address
 * itself. A subdomain cannot collide: the SaaS registry enforces global
 * uniqueness before a name is ever minted.
 *
 * 301, NOT 404: links and embeds already shared must keep working, so resolve the
 * tenant and send the visitor to the right address permanently. Google then
 * consolidates onto the subdomain, which is what the canonical already claims.
 *
 * SELF-HOSTED IS UNTOUCHED: one install, one database, the slug names a workspace
 * unambiguously and there is no other tenant to collide with. Gated on the request
 * actually being a resolved CLOUD tenant, not merely on a cloud host. */
if (defined('OPSIQ_CLOUD_TENANT') && OPSIQ_CLOUD_TENANT
    && $_siteKey !== ''
    && ($__hslug ?? '') !== ''
    && !$__isPortalHcPath                          // /hc is the PORTAL's — see the surface test above
    && !opsiq_help_is_housing_host()
    && !opsiq_help_host_is_help_owned()) {          // already ON the subdomain: never redirect to self
    $__ownHost = '';
    $__hmFile = $_opsiqRoot . '/opsiq/cache/help_hosts.php';
    if (is_file($__hmFile)) {
        $__hm = @include $__hmFile;
        if (is_array($__hm)) {
            /* Invert the generated map: the host bound to THIS workspace. Read
             * from the map rather than settings because a public request has no
             * session, so opsiq_active_site_key() cannot scope a settings read. */
            foreach ($__hm as $__h => $__sk) {
                if ((string)$__sk === $_siteKey) { $__ownHost = (string)$__h; break; }
            }
        }
    }
    if ($__ownHost !== '') {
        $__q = (string)($_SERVER['QUERY_STRING'] ?? '');
        /* Drop hslug: it named the workspace on the shared host and is meaningless
         * on the workspace's own one, where the HOST names it. */
        if ($__q !== '') {
            parse_str($__q, $__qa);
            unset($__qa['hslug']);
            $__q = http_build_query($__qa);
        }
        header('Location: https://' . $__ownHost . '/' . ($__q !== '' ? '?' . $__q : ''), true, 301);
        exit;
    }
    /* No subdomain claimed yet → no redirect. Keep serving the slug URL rather
     * than 404, so a workspace that has not claimed its address is reachable
     * instead of dark. */
}

$_settings = HelpCenter::getSettings($_siteKey);

/* PHASE7_2026-08-07 — LIVE PREVIEW.
 *
 * `?_hcpreview=<token>` layers the Studio's UNSAVED settings over the stored ones, in
 * memory only, for this one request. It exists because the Studio's Preview button was
 * a plain link to the public URL: opening it counted as a real visit, so an operator
 * checking their own work inflated their own view counts and wrote analytics rows —
 * and it could not show unsaved changes at all, because it rendered what was stored.
 *
 * The token is minted only by an admin with `manage_knowledge`, carries the workspace it
 * was minted for, expires in 15 minutes, and its payload was filtered to registered keys
 * with legal enum values before it was ever written. A preview therefore cannot render
 * anything the Studio could not have saved.
 *
 * `$_isHcPreview` gates the two write paths further down. It is set here, before any of
 * them, and is deliberately a separate flag rather than "did any override apply" — a
 * preview of an unchanged page is still a preview and must still not be counted. */
$_isHcPreview = false;
if (!empty($_GET['_hcpreview']) && class_exists('\OpsIQ\Kb\HcPreview')) {
    $__pvOverrides = \OpsIQ\Kb\HcPreview::consume((string)$_GET['_hcpreview'], (string)$_siteKey);
    if (is_array($__pvOverrides) && $__pvOverrides) {
        $_settings    = array_merge($_settings, $__pvOverrides);
        $_isHcPreview = true;
    } else {
        /* A bad, expired or foreign token must not silently render the live page as if
         * it were a preview — the operator would trust numbers that were being counted. */
        $_isHcPreview = true;   // still suppress writes; just no overrides applied
    }
    /* Never indexable, never cached by a proxy, and never a referrer leak of the token. */
    header('X-Robots-Tag: noindex, nofollow', true);
    header('Cache-Control: no-store, private', true);
    /* Referrer-Policy is NOT set here. `.htaccess` line 10 uses `Header always set`,
     * which overrides anything PHP sends, so a line here would be dead code that looks
     * like protection. The server-wide value is `strict-origin-when-cross-origin`,
     * which already does the job that matters: on a cross-origin navigation only the
     * ORIGIN is sent, so the token in this query string never reaches a third party. */
}

/* PHASE_HC_OWN_DOMAIN_SEO — one address for search engines.
 *
 * With an own domain configured, the SAME Help Center is reachable at two hosts: the
 * customer's (via their proxy / Cloudflare worker) and the original opsiqai.com URL.
 * Google indexes both and splits the ranking — the canonical alone is a hint, not a
 * rule. So when the request arrives on the ORIGINAL host we 301 to the own domain:
 * bots consolidate, humans land on the branded address, and the canonical below then
 * agrees with the URL actually served.
 *
 * NEVER redirect (each of these would break something real):
 *   • the widget panel / any ?embed=1 — it is an iframe served from opsiqai.com by
 *     design; redirecting it cross-origin would break the widget outright,
 *   • ?_hcajax / ?hca beacons — XHR must not be bounced,
 *   • non-GET,
 *   • a request already on the own domain (or proxied to it) — that is the loop guard,
 *   • the PORTAL's /hc ($__isPortalHcPath, defined near the top of this file), and
 *   • /sitemap.xml and /robots.txt on a VERIFIED proxied host. Both files describe
 *     the host they are SERVED on, so bouncing them to another host publishes
 *     nothing for the host that was actually asked: support.nabtech.co/robots.txt
 *     301'd to kb.nabtech.co and the portal address ended up with no directives and
 *     no sitemap at all. Same reasoning as the /hc exemption above — that address
 *     is the PORTAL's surface, and this field is the STANDALONE help center's.
 *
 * ON /hc: this field is the STANDALONE help center's address and /hc is the PORTAL's
 * help center, which has its own "Your own domain" field (Support Portal settings →
 * opsiq_portal_domains.surface='portal'). Without that guard, setting an address here
 * silently 301'd the portal's /hc away too — carrying REQUEST_URI through, so visitors
 * landed on <owndomain>/hc/<slug>, an address that exists nowhere.
 */
/* HELP_DOMAIN_OVERRIDE — a verified Help-only host wins on cloud and self-hosted. */
if (!empty($_settings['custom_domain'])
    && !$__isPortalHcPath
    && !($__fwdSeoReq && $__fwdSurface !== '')) {
    $__ownHost = strtolower(trim((string)$_settings['custom_domain']));
    $__ownHost = preg_replace('~^https?://~', '', $__ownHost);   // accept a pasted URL
    $__ownHost = trim(explode('/', $__ownHost)[0]);
    $__ownHost = preg_replace('/[^a-z0-9.\-:]/', '', (string)$__ownHost);

    /* HELP_PROXY_CANONICAL_LOOP — the deployed Cloudflare Worker fetches the
     * stable /help/<slug> origin and forwards its public hostname. If this
     * workspace was configured before help_hosts.php synchronization existed,
     * the empty map makes the general forwarded-host resolver reject that host;
     * the canonical redirect then sends the Worker back to itself until
     * Cloudflare reports 522. The slug already pinned the workspace, so the
     * exact stored custom_domain is a safe request-local recovery signal. */
    $__configuredProxyHost = function_exists('opsiq_help_configured_proxy_host')
        ? opsiq_help_configured_proxy_host($__ownHost)
        : '';
    if ($__configuredProxyHost !== '') {
        $GLOBALS['_opsiq_help_runtime_fwd_host'] = $__configuredProxyHost;
    }

    $__curHost = strtolower((string)(opsiq_help_fwd_host() ?: ($_SERVER['HTTP_HOST'] ?? '')));
    $__isGet   = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
    /* `hcfull` = the full-embed loader's fetch (help_widget.php mode=full). Script
     * fetch, injected inline on the customer's page, never a navigation and never
     * indexed here — so it is exempt exactly like ?embed=1 and ?_hcajax, and for the
     * same reason: redirecting it gains no SEO and costs a cross-origin CORS hop. */
    $__isEmbed = !empty($_GET['embed']) || !empty($_GET['widget']) || !empty($_GET['hcfull']);
    $__isXhr   = !empty($_GET['_hcajax']) || isset($_GET['hca']);

    /* NEVER honour a PLATFORM-owned host here (…opsiq.help / …help.opsiqai.com).
     * Cloud subdomains already redirect automatically, driven by the host map above,
     * which follows a claim or a rename on its own. If an operator pasted their OpsIQ
     * subdomain into this box and later renamed it, this field would keep pointing at
     * the OLD name — and that name is RELEASED on rename and can be claimed by another
     * tenant, so we would be sending their visitors to someone else's help center.
     * The automatic path owns those hosts; this field is only for a genuine own domain. */
    $__isPlatformHost = false;
    if ($__ownHost !== '') {
        if (!function_exists('opsiq_tenant_suffixes')) {
            $__tsf = __DIR__ . '/opsiq/opsiq.tenant_suffix.php';
            if (is_file($__tsf)) require_once $__tsf;
        }
        if (function_exists('opsiq_tenant_suffixes')) {
            foreach (opsiq_tenant_suffixes() as $__sfx) {
                $__sfx = strtolower(trim((string)$__sfx));
                if ($__sfx !== '' && substr($__ownHost, -strlen('.' . $__sfx)) === '.' . $__sfx) { $__isPlatformHost = true; break; }
            }
        }
    }

    if ($__ownHost !== '' && !$__isPlatformHost && $__isGet && !$__isEmbed && !$__isXhr
        && $__curHost !== '' && $__curHost !== $__ownHost) {
        $__uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
        $__to  = 'https://' . $__ownHost . ($__uri !== '' ? $__uri : '/');
        header('Location: ' . $__to, true, 301);
        header('Cache-Control: no-store');   /* never cache a redirect we may turn off */
        exit;
    }
    unset($__ownHost, $__configuredProxyHost, $__curHost, $__isGet, $__isEmbed, $__isXhr, $__uri, $__to);
}

/* PHASE_HC_I18N — the interface catalogue + the shared locale list. Loaded here,
 * before the first label resolves. Guarded because a release that ships without it
 * must degrade to plain English, not fatal. */
if (is_file($_opsiqRoot . '/opsiq/opsiq.hc_i18n.php')) {
    require_once $_opsiqRoot . '/opsiq/opsiq.hc_i18n.php';
}

/* ── PHASE_HC_I18N — resolve the visitor's language ──────────────────────────
 * OFF unless the operator enabled it AND picked at least one extra language.
 * ?lang= wins (it rides $_hcPersist, so it survives every click), then the
 * visitor's cookie, then the source language. An unoffered or unknown code falls
 * back to the source rather than 404ing or rendering half-translated. */
$_i18nOn      = !in_array(strtolower((string)($_settings['i18n_enabled'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
$_i18nSource  = 'en';
$_i18nLocales = ['en'];
$_locale      = 'en';
$_localeDir   = 'ltr';
if ($_i18nOn && function_exists('opsiq_hc_locales_enabled')) {
    $_i18nSource  = opsiq_hc_locale_sanitize((string)($_settings['i18n_source'] ?? 'en'));
    /* PHASE_HC_CUSTOM_LOCALE — operator-added languages count as enabled too. */
    $_i18nCustom  = function_exists('opsiq_hc_custom_locale_map') ? array_keys(opsiq_hc_custom_locale_map($_settings['i18n_custom_locales'] ?? '')) : [];
    $_i18nLocales = opsiq_hc_locales_enabled($_settings['i18n_locales'] ?? '', $_i18nSource, $_i18nCustom);
    $_i18nOn      = count($_i18nLocales) > 1;      /* one language is not multilingual */
    /* PRECEDENCE: an explicit choice ALWAYS wins and is remembered — ?lang= or the
     * hc_lang cookie. Only when the visitor has made no choice at all do we consider
     * auto-detect (PHASE_HC_I18N_AUTODETECT). So a visitor who once picked English in
     * France keeps English forever; a first-time visitor from France gets French. */
    $__explicit = '';
    if (isset($_GET['lang']))                 $__explicit = (string)$_GET['lang'];
    elseif (isset($_COOKIE['hc_lang']))       $__explicit = (string)$_COOKIE['hc_lang'];
    $__want = opsiq_hc_locale_sanitize($__explicit);

    if ($__want === '' && trim((string)($_settings['i18n_autodetect'] ?? '')) !== ''
        && !in_array(strtolower((string)$_settings['i18n_autodetect']), ['0','off','false','no'], true)
        && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
        try {
            $__cc = \OpsIQ\Kb\HcTranslator::detectCountry();
            if ($__cc !== '') {
                $__auto = \OpsIQ\Kb\HcTranslator::countryToLocale($__cc, $_i18nLocales);
                if ($__auto !== '') $__want = $__auto;   // still just a candidate; validated below
            }
        } catch (\Throwable $e) {}
    }

    $_locale    = in_array($__want, $_i18nLocales, true) ? $__want : $_i18nSource;
    $_localeDir = opsiq_portal_locale_dir($_locale);

    /* PERSIST THE CHOICE SERVER-SIDE (15 September 2026).
     *
     * hc_lang was written only by JavaScript, after the page had loaded. PHP cannot
     * read a cookie that does not exist yet, so on a first visit with ?lang= the
     * portal design bundle — and the SPA's first portal_config fetch — both resolved
     * the source language, and the visitor got a page in two languages. Writing it
     * here, before any output, means every later request on this host (the AJAX
     * config included) inherits the choice, which is also what makes the second page
     * of a visit arrive correct rather than corrected.
     *
     * Only when the visitor actually asked: an autodetected guess must not be stored
     * as a decision, or "it remains until changed again" would start meaning "until
     * you travel". Same rule as the precedence comment above. */
    if (isset($_GET['lang']) && $_locale !== '' && !headers_sent()
        && (string)($_COOKIE['hc_lang'] ?? '') !== $_locale) {
        @setcookie('hc_lang', $_locale, [
            'expires'  => time() + 31536000,
            'path'     => '/',
            'secure'   => !empty($_SERVER['HTTPS']),
            'httponly' => false,           // the SPA and the HC script both read it
            'samesite' => 'Lax',
        ]);
        $_COOKIE['hc_lang'] = $_locale;
    }
}
/* PHASE_HC_I18N_AI — the operator's OWN typed text, translated.
 *
 * $__txt below returns the operator's words FIRST and never translates them, which is
 * correct in the source language and wrong in every other: an operator who wrote
 * "Welcome to Nabtech Support" got that English line on the French page. Overlaying the
 * translated strings onto $_settings here — BEFORE $__txt/$__searchBtn/$__titleTxt close
 * over it — makes all three translate with no change to any of them: "the operator's
 * words win" still holds, the words are just in the visitor's language.
 *
 * MUST run after $_locale is resolved and before line ~428. Source language is skipped:
 * there is nothing to translate INTO, and overlaying would be a no-op round-trip. */
if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
    try {
        $__ovl = \OpsIQ\Kb\HcTranslator::settingsOverlay($_siteKey, $_locale);
        if ($__ovl) $_settings = array_merge($_settings, $__ovl);
    } catch (\Throwable $e) { /* never take the public page down for a translation */ }
}

$_i18nStrings = ($_i18nOn && function_exists('opsiq_hc_i18n_strings')) ? opsiq_hc_i18n_strings($_locale) : [];
/* PHASE_HC_CHROME_AI — the AI-translated interface chrome wins for EVERY locale
 * (owner directive: "everything front end should be driven by AI"). The shipped
 * hand-translated catalogue above is the FLOOR: it renders until the runner has
 * translated this language, and if a call ever failed — so the page can never
 * drop to raw English. Precedence: AI overlay → shipped catalogue → English.
 * A custom language has no catalogue at all, so the overlay IS its chrome. */
if ($_i18nOn && $_i18nStrings && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
    try {
        $__chrome = \OpsIQ\Kb\HcTranslator::chromeOverlay((string)$_siteKey, (string)$_locale);
        if ($__chrome) $_i18nStrings = array_merge($_i18nStrings, array_filter($__chrome, 'strlen'));
    } catch (\Throwable $e) { /* the shipped catalogue is an acceptable floor */ }
}
$_i18nSwitcherNav = !in_array(strtolower((string)($_settings['i18n_switcher_in_nav'] ?? 'on')), ['0', 'off', 'false', 'no'], true);

/* THE RESOLUTION CONTRACT: operator's own text → catalogue translation → English.
 * Every editable label already routes through this ONE helper, and every one of
 * those settings defaults to blank — so "blank" means "never touched", which is
 * exactly when a translation should apply. An operator who wrote their own hero
 * heading keeps their words in every language; we do not translate over them. */
$__txt = function ($key, $default) use ($_settings, $_i18nStrings) {
    $v = (string)($_settings[$key] ?? '');
    if (trim($v) !== '') return $v;                       /* the operator's own words win */
    static $map = [
        'text_hero_heading'       => 'hero_heading',
        'text_hero_sub'           => 'hero_sub',
        'text_search_placeholder' => 'search_ph',
        'text_browse_label'       => 'browse_label',
        'text_popular_label'      => 'popular_label',
        'text_no_results'         => 'no_results',
        'text_feedback_q'         => 'feedback_q',
        'text_contact_prompt'     => 'contact_prompt',
    ];
    $k = $map[$key] ?? '';
    if ($k !== '' && isset($_i18nStrings[$k]) && $_i18nStrings[$k] !== '') return $_i18nStrings[$k];
    return $default;
};
/* For the strings that are NOT operator-editable (breadcrumbs, buttons, "min read"
 * …). Same contract minus the first step. */
$__t = function ($key, $default) use ($_i18nStrings) {
    return (isset($_i18nStrings[$key]) && $_i18nStrings[$key] !== '') ? $_i18nStrings[$key] : $default;
};

/* PHASE_HC_I18N — the hero search button.
 * Each of the 20 layouts ships its own verb ("Begin", "Explore", "Takeoff", "Ping"
 * …) — that word is part of the theme's character, so it stays the DEFAULT. The
 * order is therefore:
 *     operator's own word  →  translated generic verb  →  the theme's word
 * The middle step matters: "Takeoff" is untranslatable flavour, and shipping it
 * inside an Arabic page would read as broken. In the source language the theme
 * keeps its personality; in every other language it falls back to a plain,
 * correct verb. Set your own in Typography and it wins everywhere. */
$__searchBtn = function ($themeDefault) use ($_settings, $_i18nStrings, $_i18nOn, $_locale, $_i18nSource) {
    $v = trim((string)($_settings['text_search_button'] ?? ''));
    if ($v !== '') return $v;
    if ($_i18nOn && $_locale !== $_i18nSource && !empty($_i18nStrings['search_btn'])) return $_i18nStrings['search_btn'];
    return $themeDefault;
};

/* PHASE_HC_I18N — headings whose setting DEFAULTS TO A WORD rather than to blank
 * ("Resources", "Featured"), so "blank = untouched" cannot detect them. If the
 * value is still the shipped English default, nobody chose it and it should
 * translate; anything else is the operator's own and is kept. */
$__titleTxt = function ($key, $enDefault, $catKey) use ($_settings, $_i18nStrings) {
    $v = trim((string)($_settings[$key] ?? ''));
    if ($v !== '' && $v !== $enDefault) return $v;
    return !empty($_i18nStrings[$catKey]) ? $_i18nStrings[$catKey] : $enDefault;
};
$_embed    = !empty($_GET['embed']);
/* PHASE_HC_WIDGET — the Help Center WIDGET (help_widget.php) shows this page in a
 * ~420px slide-in panel. That is a different job from a full-page embed: the wide
 * home (hero + tile grid + featured row) does not fit a narrow column. `widget=1`
 * turns on a compact, single-column layout tuned for the panel. It is requested ONLY
 * by help_widget.php, so a plain ?embed=1 (full-page embeds) renders exactly as
 * before — this must never change the embed. */
$_widget   = $_embed && !empty($_GET['widget']);
/* Which edge the panel opened against. The page needs it so its scrollbar can sit
 * on the panel's INNER edge: against the outer edge it lands right next to the host
 * page's own scrollbar and you get two rails side by side. */
$_widgetSide = (($_GET['side'] ?? 'right') === 'left') ? 'left' : 'right';
/* PHASE_HC_WIDGET_LOOK (2026-09-14) — the panel's DESIGN, sent by help_widget.php.
 * `wd=<variant>` asks this page to draw the Help look (the head, the floating search,
 * the card skin: opsiq/assets/hc-widget-look.css) for that variant; absent means the
 * classic bar-and-page. `wh=0` says the operator turned the panel header off, so no
 * head is drawn. Whitelisted through the shared vocabulary, because the value lands in
 * a class name. Widget mode only: a plain ?embed=1 can never carry a skin. */
if (!function_exists('opsiq_hc_widget_variant_sanitize')) {
    $__wdF = __DIR__ . '/opsiq/opsiq.hc_widget_look.php';
    if (is_file($__wdF)) { try { require_once $__wdF; } catch (\Throwable $e) {} }
}
$_wdVariant = ($_widget && isset($_GET['wd']) && function_exists('opsiq_hc_widget_variant_sanitize'))
    ? opsiq_hc_widget_variant_sanitize((string)$_GET['wd']) : '';
$_wdHead = (string)($_GET['wh'] ?? '1') !== '0';

/* PHASE_HELP_SLUG — the help center is reachable ONLY at its per-workspace path
 * /help.php/<slug> (or an embed/programmatic call carrying ?site_key). A bare
 * /help.php with no workspace signal is NOT a public entry point: return 404
 * instead of silently falling back to the default/installed workspace. On the
 * shared cloud URL this stops opsiqai.com/help.php from opening a generic help
 * center to anyone who types it directly. */
$__explicitWorkspace = ($__hslug !== '' && $_siteKey !== '') || ($__reqKey !== '');
/* PHASE_PORTAL_P7.5 — on the branded portal host, being there IS the explicit
 * entry point only for the Portal's real /hc surface. A standalone Help-Center-
 * owned hostname is also explicit at its ROOT. Bare /help is never explicit:
 * every path-based workspace has a slug, so allowing it would silently select
 * the installed/default workspace. */
$__helpRequestPath = function_exists('opsiq_help_request_path')
    ? rtrim((string)opsiq_help_request_path(), '/')
    : rtrim((string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/'), '/');
$__helpOwnedRoot = function_exists('opsiq_help_host_is_help_owned')
    && opsiq_help_host_is_help_owned()
    && ($__helpRequestPath === '' || $__helpRequestPath === '/');
if (!$__explicitWorkspace && $__isPortalHelpHost && $_siteKey !== ''
    && ($__isPortalHcPath || $__helpOwnedRoot)) {
    $__explicitWorkspace = true;
}
/* PHASE_PORTAL_PROXY_SEO — /sitemap.xml and /robots.txt on a VERIFIED proxied
 * host. The generated map names the workspace for that host exactly as it does
 * for a help-owned hostname; the proxy only moves the name out of Host and into
 * X-Forwarded-Host, so the entry point is every bit as explicit. Deliberately
 * limited to those two files: they are the host's own SEO documents and have no
 * other address, whereas widening this to any passed-through path would publish
 * the help center at a second URL (…/help.php) and split its ranking. */
if (!$__explicitWorkspace && $__fwdSeoReq && $__fwdSurface !== '' && $_siteKey !== '') {
    $__explicitWorkspace = true;
}
if (!$__explicitWorkspace) {
    http_response_code(404);
    header('Content-Type: text/html; charset=utf-8');
    echo '<!doctype html><html lang="en"><head><meta charset="utf-8">'
       . '<meta name="robots" content="noindex"><title>Help Center not found</title></head><body>'
       . '<div style="max-width:460px;margin:16vh auto;text-align:center;padding:24px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif">'
       . '<div style="font-size:44px;margin-bottom:10px">📚</div>'
       . '<h1 style="font-size:20px;color:#1e293b;margin:0 0 8px">Help Center not found</h1>'
       . '<p style="font-size:14px;line-height:1.6;color:#475569;margin:0">This help center lives at its own address. Please use the link from the site you came from.</p>'
       . '</div></body></html>';
    exit;
}

/* ── PHASE_HC_ANALYTICS — the Help Center's OWN ingest endpoint ──────────────
 *
 * POST /help.php/<slug>?hca=1   {events:[…]}
 *
 * The Help Center does NOT ride on the chat widget's beacon: a tenant may run a
 * help center with no widget anywhere, and then nothing would ever be recorded.
 * It does not touch the SaaS either. This is its own endpoint, on its own page,
 * writing to its own tables — and it reuses only the shared detection engine for
 * country / flag / browser / device (opsiq.geo_detect.php).
 *
 * Refuses outright unless the workspace has switched analytics on. Fail-soft: a
 * broken beacon must never break the page it is measuring, so it always answers
 * 204 and never leaks a reason. */
if (isset($_GET['hca']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
    @header('Content-Type: application/json; charset=utf-8');
    @header('Cache-Control: no-store');
    try {
        if (empty($GLOBALS['_isHcPreview']) && class_exists('\\OpsIQ\\Kb\\HcAnalytics') && \OpsIQ\Kb\HcAnalytics::enabled($_siteKey)) {
            $raw  = (string)file_get_contents('php://input');
            $body = json_decode($raw, true);
            if (is_array($body)) {
                $client = [
                    'visitor_id' => (string)($body['vid'] ?? ''),
                    'session_id' => (string)($body['sid'] ?? ''),
                    'url'        => (string)($body['url'] ?? ''),
                    'referrer'   => (string)($body['ref'] ?? ''),
                    'language'   => (string)($body['lang'] ?? ''),
                    'viewport_w' => (int)($body['vw'] ?? 0),
                    'viewport_h' => (int)($body['vh'] ?? 0),
                ];
                // A batch, so a reader leaving the page can flush everything in
                // one keepalive request instead of racing the unload.
                /* PHASE_HC_I18N_ANALYTICS — the display locale is a batch-level fact
                 * (the whole page is one language), so stamp it onto every event rather
                 * than sending it per-event. */
                $__hcLoc = preg_replace('/[^a-z-]/', '', strtolower((string)($body['hc_locale'] ?? '')));
                $events = isset($body['events']) && is_array($body['events']) ? $body['events'] : [];
                /* REPLY_FEEDBACK P8.1 — surface identity: readers on the
                 * PORTAL's /hc path record as the portal surface, so the HC
                 * analytics module can tell page / widget / portal apart.
                 * The server-side path flag decides — never a client field. */
                $__hcSurface = $__isPortalHcPath
                    ? \OpsIQ\Kb\HcAnalytics::SURFACE_PORTAL
                    : \OpsIQ\Kb\HcAnalytics::SURFACE_PAGE;

                /* PHASE_HC_CONSENT_RACE_2026-08-17 — THE SERVER MUST DECIDE TOO.
                 * The browser gate is the visitor's experience; it is not a control. This
                 * endpoint is public, unauthenticated and CORS-open, so a direct POST wrote
                 * events regardless of what the visitor chose. The consent decision lives in
                 * a COOKIE, which the server can read directly — no client field is trusted.
                 * Same three-way answer as the client: an explicit decision wins; otherwise,
                 * if this workspace has consent switched on, an undecided visitor is not
                 * recorded; a workspace with consent off is unchanged. */
                $__consentOk = true;
                try {
                    $__cats = (string)($_COOKIE['opsiq_consent_cats'] ?? '');
                    if ($__cats !== '') {
                        $__dec = json_decode(urldecode($__cats), true);
                        $__consentOk = is_array($__dec) && !empty($__dec['analytics']);
                    } else {
                        $__ce = \Illuminate\Database\Capsule\Manager::table('opsiq_settings')
                            ->where('setting', 'site:' . $_siteKey . ':cookie_consent_enabled')->value('value');
                        if (in_array(strtolower(trim((string)$__ce)), ['1', 'on', 'true', 'yes'], true)) {
                            $__consentOk = false;          // banner on, visitor has not chosen
                        }
                    }
                } catch (\Throwable $e) { /* unknown → behave exactly as before */ }
                if (!$__consentOk) { http_response_code(204); exit; }

                /* PHASE_HC_INGEST_THROTTLE_2026-08-17 — the 20-event cap was per REQUEST.
                 * This endpoint is public, unauthenticated and CORS-open, and nothing
                 * limited how many requests one client could make — so `array_slice(…, 20)`
                 * bounded nothing against a loop, and the events/sessions tables could be
                 * inflated without limit. A real visitor never approaches this ceiling: a
                 * page view sends a handful of events over a session. Fails OPEN if the
                 * limiter is unavailable, exactly like the portal's own throttle. */
                if (class_exists('\OpsIQ\Security\RouteRateLimiter')) {
                    try {
                        /* Behind the own-domain reverse proxy (kb.nabtech.co → opsiqai.com)
                         * every visitor arrives as the PROXY's address, so an IP-only key
                         * would throttle all real visitors collectively once one of them
                         * was busy. Key on the visitor's session id when the client sends
                         * one — a real page always does — and fall back to IP for a client
                         * that sends none (which is exactly the anonymous-loop case). */
/* INDEPENDENT AUDIT 2026-08-17 — a rate-limit key must contain NOTHING the
                         * CALLER PICKS. This keyed on $body['sid'] — a field in the client's own
                         * JSON — so a fresh random sid per request minted a fresh 120/60s budget
                         * every time and the cap never engaged (it also created a new session row
                         * per request, inflating the very table the throttle exists to protect).
                         * The IP fallback read forwarding headers with no trusted-proxy check.
                         * Both replaced by the authoritative peer IP. */
                        $__ipRaw = class_exists('\\OpsIQ\\Security\\IpFirewall')
                            ? (string)\OpsIQ\Security\IpFirewall::clientIp()
                            : (string)($_SERVER['REMOTE_ADDR'] ?? '');
                        $__rlKey = 'hc_analytics_ingest:' . $_siteKey . ':ip:' . $__ipRaw;
                        $__rl = \OpsIQ\Security\RouteRateLimiter::hit($__rlKey, 120, 60);
                        if (empty($__rl['allowed'])) { http_response_code(204); exit; }
                    } catch (\Throwable $e) { /* limiter down → behave as before */ }
                }

                foreach (array_slice($events, 0, 20) as $ev) {
                    if (!is_array($ev)) continue;
                    if ($__hcLoc !== '' && empty($ev['locale'])) $ev['locale'] = $__hcLoc;
                    \OpsIQ\Kb\HcAnalytics::record($_siteKey, $__hcSurface, $ev, $client);
                }
            }
        }
    } catch (\Throwable $e) {
        @error_log('[opsiq][hc_analytics][ingest] ' . $e->getMessage());
    }
    http_response_code(204);
    exit;
}

/* Embed mode — allow this page to be framed on any customer domain (the Help
 * Center widget renders it inside a slide-in panel iframe on the customer's
 * own site). Without this, default framing protections would blank the panel. */
if ($_embed || !empty($_isHcPreview)) {
    /* PHASE_HC_PRESETS_20 — a preview render is framed by the Studio (preset gallery /
     * live preview) which lives on the OpsIQ host, not on this custom domain. The token
     * is single-use and workspace-bound, so this widens nothing durable. */
    @header_remove('X-Frame-Options');
    header('Content-Security-Policy: frame-ancestors *;');
} else {
    /* PHASE_HC_WIDGET_LOOK (2026-09-14) — .htaccess no longer sets X-Frame-Options for this
     * file (the embed must be frameable ANYWHERE, and the server ignores env-conditional
     * headers), so the public page states its own protection here. */
    header('X-Frame-Options: SAMEORIGIN');
}

/* ── Enabled gate ─────────────────────────────────────────────────────────────
 * The Help Center only serves when the admin has turned it on for this
 * workspace. Applies to the page, the sitemap, and the embed widget. */
if (empty($_settings['enabled'])) {
    if (isset($_GET['sitemap'])) {
        header('Content-Type: application/xml; charset=utf-8');
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
           . '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>';
        exit;
    }
    if (isset($_GET['robots'])) {
        // Unpublished help center — tell crawlers to stay away entirely.
        header('Content-Type: text/plain; charset=utf-8');
        echo "User-agent: *\nDisallow: /\n";
        exit;
    }
    http_response_code(404);
    header('Content-Type: text/html; charset=utf-8');
    echo '<!doctype html><html lang="en"><head><meta charset="utf-8">'
       . '<meta name="viewport" content="width=device-width, initial-scale=1">'
       . '<meta name="robots" content="noindex"><title>Help Center unavailable</title></head>'
       . '<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;background:#f8fafc;color:#475569">'
       . '<div style="max-width:460px;margin:16vh auto;text-align:center;padding:24px">'
       . '<div style="font-size:44px;margin-bottom:10px">📚</div>'
       . '<h1 style="font-size:20px;color:#1e293b;margin:0 0 8px">Help Center isn\'t available</h1>'
       . '<p style="font-size:14px;line-height:1.6;margin:0">This help center hasn\'t been published yet. Please check back soon.</p>'
       . '</div></body></html>';
    exit;
}

/* ── Sitemap output ──────────────────────────────────────────────────────────
 * PHASE_HELP_PRETTY_URL — the sitemap must advertise the SAME address the pages
 * canonicalise to, or it hands Google one URL and the page claims another.
 * $helpBase is computed further down (after routing), so the public help base is
 * derived here, from the same rules. */
if (isset($_GET['sitemap'])) {
    $proto   = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    /* PHASE_HELP_PROXY — when proxied under the customer's own domain, advertise
     * that domain + the public mount so Google indexes the pages there, not on
     * opsiqai.com. Falls back to Host when there is no proxy in front. */
    $__smFwd    = opsiq_help_fwd_host();
    $__smProxied = ($__smFwd !== '' && strcasecmp($__smFwd, (string)($_SERVER['HTTP_HOST'] ?? '')) !== 0);
    if ($__smProxied) {
        $__smProto = strtolower(trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))) === 'http' ? 'http' : 'https';
        $baseUrl   = $__smProto . '://' . $__smFwd;
    } else {
        $baseUrl = $proto . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
    }

    $__smHost   = strtolower(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? '')));
    $__smPortal = defined('OPSIQ_PORTAL_HELP_HOST')
        || (function_exists('opsiq_tenant_host_in_namespace')
            ? opsiq_tenant_host_in_namespace($__smHost)
            : (bool)preg_match('/(^|\.)help\.opsiqai\.com$/', $__smHost));

    /* PHASE_HELP_HOST_OWNERSHIP — a host the HELP CENTER owns serves the help
     * center at its ROOT, so its sitemap must advertise the ROOT.
     *
     * `/hc` is the PORTAL's own configured help center; `/help` is the standalone
     * one. They are two different approaches and the base differs accordingly.
     * The test above says "any *.help.opsiqai.com is a portal host", which was
     * true when the portal owned every such host — but a host allocated from the
     * Help Center Studio now roots the standalone help center instead, and this
     * branch would still emit `<loc>…/hc</loc>`. The canonical (built from the
     * real request) already says the root, so the sitemap and the canonical would
     * name DIFFERENT addresses for the same page — the exact contradiction that
     * de-indexes a KB. Ownership decides the base. */
    $__smHelpOwnedHost = false;
    if ($__smHost !== '') {
        $__smHelpOwnedFile = $_opsiqRoot . '/opsiq/cache/help_hosts.php';
        if (is_file($__smHelpOwnedFile)) {
            $__smHm = @include $__smHelpOwnedFile;
            if (is_array($__smHm) && !empty($__smHm[$__smHost])) $__smHelpOwnedHost = true;
        }
        unset($__smHelpOwnedFile, $__smHm);
    }

    if ($__smProxied && $__fwdSurface !== '') {
        /* PHASE_PORTAL_PROXY_SEO — on a VERIFIED proxied host the mount comes
         * from the surface that OWNS the host, never from X-Forwarded-Prefix.
         * The templates only set that header meaningfully for /hc: on the
         * passed-through /sitemap.xml the Worker sends "/" and the Apache
         * template sends "/hc", so trusting it would advertise the ROOT of a
         * portal host whose help center lives at /hc — contradicting every
         * canonical the pages themselves emit, which is what de-indexes a KB. */
        $__smBase = rtrim($baseUrl . ($__fwdSurface === 'portal' ? '/hc' : ''), '/');
    } elseif ($__smProxied) {
        $__smMount = '/' . trim((string)preg_replace('~[^A-Za-z0-9/_\-]~', '', (string)($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? '')), '/');
        if (!array_key_exists('HTTP_X_FORWARDED_PREFIX', $_SERVER) && $__smMount === '/') $__smMount = '/help';
        $__smBase = rtrim($baseUrl . $__smMount, '/');                 // proxied on the customer's domain
    } elseif ($__smHelpOwnedHost) {
        $__smBase = rtrim($baseUrl, '/');                               // help-owned host: the HC IS the root
    } elseif ($__smPortal) {
        $__smBase = $baseUrl . '/hc';                                   // branded portal host
    } elseif ($__hslug !== '' && $_siteKey !== '' && is_dir($_opsiqRoot . '/help')) {
        $__smBase = $baseUrl . '/help/' . rawurlencode($__hslug);       // pretty cloud URL
    } else {
        $__smBase = $baseUrl . '/help.php';                             // self-install on its own domain
    }

    /* PHASE_HC_SEO_2026-08-27 — the apex replay happens HERE, not before this runs.
     *
     * It used to `readfile()` the apex file and exit up at the forwarded-host guard,
     * which is above the point where the workspace is resolved from the real HTTP_HOST.
     * So a customer's own help-center domain never reached this generator and was served
     * OpsIQ's MARKETING sitemap instead — measured live on a customer host,
     * /help.php?sitemap=1 returned the 51 KB apex file naming `opsiqai.com` throughout.
     *
     * The anti-forgery property is intact: a forged X-Forwarded-Host still cannot NAME a
     * workspace (only the generated host map or the real HTTP_HOST can), so if nothing
     * resolved there is still nothing to generate and the apex file is still what goes
     * out. The `private, no-store` header set at the guard keeps a tenant-pinned answer
     * out of the shared-URL CDN cache either way. */
    if (!empty($__seoReplayApex) && (string)$_siteKey === '') {
        if (!headers_sent()) header('Content-Type: application/xml; charset=utf-8', true);
        if (is_file($_opsiqRoot . '/sitemap.xml')) readfile($_opsiqRoot . '/sitemap.xml');
        exit;
    }
    header('Content-Type: application/xml; charset=utf-8');
    /* PHASE_HC_I18N_SEO (I3) — advertise the translated versions, gated by the SAME
     * opt-in flag as the <head> alternates so the two can never disagree (a sitemap
     * that claims locales the pages do not link back to is a Search Console error).
     * Flag off, or a single language ⇒ the original plain sitemap, byte for byte. */
    $__smI18n = (!empty($_i18nOn)
        && trim((string)($_settings['i18n_seo_alternates'] ?? '')) !== ''
        && !in_array(strtolower((string)$_settings['i18n_seo_alternates']), ['0', 'off', 'false', 'no'], true));
    echo HelpCenter::buildSitemap(
        $_siteKey,
        $baseUrl,
        $__smBase,
        $__smI18n && is_array($_i18nLocales) ? $_i18nLocales : [],
        (string)($_i18nSource ?? 'en')
    );
    exit;
}

/* ── robots.txt output ───────────────────────────────────────────────────────
 * Served (via the .htaccess rewrite) at this help host's /robots.txt so search
 * engines — and OpsIQ's own KB importer — discover the article sitemap. */
if (isset($_GET['robots'])) {
    /* PHASE_PORTAL_PROXY_SEO — name the address the visitor typed. Behind a
     * proxy `Host` is the install, so the tenant's own robots.txt pointed
     * crawlers at opsiqai.com/sitemap.xml: the MARKETING file. */
    $__rbFwd     = $__fwdCustHost !== '' ? $__fwdCustHost : opsiq_help_fwd_host();
    $__rbProxied = ($__rbFwd !== '' && strcasecmp($__rbFwd, (string)($_SERVER['HTTP_HOST'] ?? '')) !== 0);
    if ($__rbProxied) {
        $proto   = strtolower(trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))) === 'http' ? 'http' : 'https';
        $baseUrl = $proto . '://' . $__rbFwd;
    } else {
        $proto   = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $baseUrl = $proto . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
    }
    /* Same deferral as the sitemap above: nothing resolved means nothing to describe. */
    if (!empty($__seoReplayApex) && (string)$_siteKey === '') {
        if (!headers_sent()) header('Content-Type: text/plain; charset=utf-8', true);
        if (is_file($_opsiqRoot . '/robots.txt')) readfile($_opsiqRoot . '/robots.txt');
        exit;
    }
    header('Content-Type: text/plain; charset=utf-8');
    echo HelpCenter::buildRobots($baseUrl . '/sitemap.xml');
    exit;
}

/* ── Resolve route ───────────────────────────────────────────────────────── */
$_articleSlug = isset($_GET['article']) ? trim((string)$_GET['article']) : '';
$_catSlug     = isset($_GET['cat'])     ? trim((string)$_GET['cat'])     : '';
$_query       = isset($_GET['q'])       ? trim((string)$_GET['q'])       : '';
$_articleId   = 0;

$_article   = null;
$_category  = null;
$_articles  = [];
$_categories= HelpCenter::listCategories($_siteKey, $_locale);
$_related   = [];

if ($_articleSlug !== '') {
    $_article = HelpCenter::getArticleBySlug($_articleSlug, $_siteKey, $_locale);
    if (!$_article) {
        /* PHASE_HC_SLUG_REDIRECT_2026-08-17 — a renamed article keeps its old links. Before
         * this, every shared, bookmarked or indexed URL died the moment a slug changed.
         * A stale slug now 301s to the article's current address (query string preserved,
         * so ?lang= survives). Only after the live lookup misses, and only for a real
         * page load — the AJAX/embed paths never redirect. */
        $__moved = (empty($_GET['_hcajax']) && strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET')
            ? HelpCenter::resolveMovedSlug($_articleSlug, $_siteKey, $_locale) : null;

        if ($__moved !== null && $__moved !== '') {
            /* hc_u() is declared ~1,600 lines below this point (a function called before its
             * declaration in a template = fatal = HTTP 500); build the URL by hand from the
             * request's own path so the redirect stays on whatever host and base served it. */
            $__qs = $_GET; $__qs['article'] = $__moved;
            unset($__qs['_hcpreview'], $__qs['hslug']);
            $__path = strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?') ?: '/';
            $__to = $__path . '?' . http_build_query($__qs);
            http_response_code(301);
            header('Location: ' . $__to);
            header('Cache-Control: public, max-age=3600');
            exit;
        }
        http_response_code(404);
    } else {
        $_articleId = (int)$_article['id'];
        /* PHASE7 — a preview is not a visit. */
        if (empty($GLOBALS['_isHcPreview'])) HelpCenter::recordView($_articleId);
        /* PHASE_HC_I18N_AI — serve the stored translation for this locale.
         *
         * The renderer reads content_formatted first and falls back to content_text, so
         * the translated body goes into content_formatted whichever field it came from.
         * A STALE translation still serves (flagged, never blank) — that is the agreed
         * contract: a slightly-out-of-date French article beats an English wall.
         * No translation at all => untouched source article, so this is invisible until
         * a run has happened. */
        if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
            try {
                $__tr = \OpsIQ\Kb\HcTranslator::articleOverlay($_siteKey, $_articleId, $_locale);
                if ($__tr) {
                    if (!empty($__tr['page_title'])) $_article['page_title'] = $__tr['page_title'];
                    if (!empty($__tr['excerpt']))    $_article['excerpt']    = $__tr['excerpt'];
                    if (!empty($__tr['body'])) {
                        $_article['content_formatted'] = $__tr['body'];
                        /* Read-time is counted off content_text; leaving the English
                         * source there would print an English word count on a
                         * translated page. */
                        $_article['content_text'] = strip_tags($__tr['body']);
                    }
                    $_articleIsTranslated = true;
                    $_articleTrStale      = !empty($__tr['_stale']);
                    /* PHASE_HC_I18N_AI — drives the machine-translation notice.
                     * Human-post-edited rows (origin='human') are excluded. */
                    $_articleTrMachine    = !empty($__tr['_machine']);
                }
            } catch (\Throwable $e) { /* never take an article down for a translation */ }
        }
        /* PHASE_HC_SECTIONS — fetch WIDE and let the related_articles rule decide
         * the count. Fetching only 4 here would starve any rule asking for more,
         * and the setting would look broken. */
        /* PHASE0.5_2026-08-09 — the operator's STRATEGY choice now reaches the query.
         * relatedArticles() implements four (category / tags / title / auto) and its own
         * docblock says "the operator's choice lives in the settings blob and is applied by
         * the callers" — this caller did not apply it, so every workspace got 'auto'
         * whatever it had chosen. The COUNT is deliberately still not passed: the
         * related_articles section rule owns it, which is why we fetch wide at 24. */
        $__relStrategy = strtolower(trim((string)($_settings['related_strategy'] ?? 'auto')));
        if (!in_array($__relStrategy, ['auto', 'category', 'tags', 'title'], true)) $__relStrategy = 'auto';
        $_related = HelpCenter::relatedArticles($_articleId, $_siteKey, 24, $__relStrategy, $_locale);
        if ($_article['category_id']) {
            $_category = null;
            foreach ($_categories as $c) {
                if ((int)$c['id'] === (int)$_article['category_id']) { $_category = $c; break; }
            }
        }
    }
} elseif ($_catSlug !== '') {
    foreach ($_categories as $c) {
        if ($c['slug'] === $_catSlug) { $_category = $c; break; }
    }
    if ($_category) {
        $_articles = HelpCenter::listPublicArticles($_siteKey, (int)$_category['id'], 50, 0, true, $_locale);
    } else {
        http_response_code(404);
    }
} elseif ($_query !== '') {
    /* PHASE_HC_SEARCH_PAGINATION_2026-08-17 — the heading said "20 results" for a query with
     * 537 matches, because 20 was the page LIMIT and there was no pager; 517 articles were
     * unreachable and the visitor was told the set was complete. Real total + pages now. */
    $_searchPage  = max(1, (int)($_GET['page'] ?? 1));
    $_searchPer   = 20;
    $__sp = HelpCenter::searchPaged($_query, $_siteKey, $_searchPer, ($_searchPage - 1) * $_searchPer, 0, ['locale'=>$_locale]);
    $_articles     = $__sp['rows'];
    $_searchTotal  = (int)$__sp['total'];
    $_searchPages  = (int)ceil($_searchTotal / $_searchPer);
}

/* PHASE_HC_I18N_AI — LISTINGS. The single-article overlay (above) only fixes the
 * detail page; the category cards and every article CARD come from these separate
 * list queries, which is why they stayed English while the article itself translated.
 * Overlay the whole collections in place so cards, categories and related all show
 * the stored translation. Guarded + try/catch: a translation must never take the
 * public page down. */
if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
    try {
        if (!empty($_categories) && is_array($_categories)) \OpsIQ\Kb\HcTranslator::overlayCategories($_categories, $_siteKey, $_locale);
        if (!empty($_category)   && is_array($_category)) { $__c1 = [$_category]; \OpsIQ\Kb\HcTranslator::overlayCategories($__c1, $_siteKey, $_locale); $_category = $__c1[0]; }
        if (!empty($_articles)   && is_array($_articles)) \OpsIQ\Kb\HcTranslator::overlayArticles($_articles, $_siteKey, $_locale);
        if (!empty($_related)    && is_array($_related))  \OpsIQ\Kb\HcTranslator::overlayArticles($_related, $_siteKey, $_locale);
    } catch (\Throwable $e) { /* never blank the page for a translation */ }
}

/* ── Helpers ─────────────────────────────────────────────────────────────── */
/* RAW on purpose: every render point hc_esc()'s $siteName, and $pageTitle feeds
 * document.title via JS (plain text). Pre-escaping here double-encoded any special
 * char — invisible for "Help Center" but the translated "Centre d'Aide" showed a
 * literal "Centre d&#039;Aide". Store raw; escape at the edge. */
$siteName   = (string)($_settings['site_name'] ?? 'Help Center');
$brandColor = preg_replace('/[^#a-fA-F0-9]/', '', (string)($_settings['brand_color'] ?? '#6c5ce7')) ?: '#6c5ce7';
$logoUrl    = htmlspecialchars((string)($_settings['logo_url']    ?? ''));
/* PHASE_HC_DARK — the dark-mode logo. Blank = the light logo serves both themes. */
$logoUrlDark = htmlspecialchars((string)($_settings['logo_url_dark'] ?? ''));
$footerText = htmlspecialchars((string)($_settings['footer_text'] ?? ''));
/* PHASE_HC_MORE_CONFIG — advanced footer: link COLUMNS (above copyright) +
 * inline LEGAL links (on the copyright row). Stored as JSON; decode + normalise
 * defensively so a malformed value can never break the page. */
$footerColumns = [];
$__fcRaw = json_decode((string)($_settings['footer_columns'] ?? '[]'), true);
if (is_array($__fcRaw)) {
    foreach ($__fcRaw as $__col) {
        if (!is_array($__col)) continue;
        $__title = trim((string)($__col['title'] ?? ''));
        $__links = [];
        foreach ((array)($__col['links'] ?? []) as $__lk) {
            if (!is_array($__lk)) continue;
            $__lb = trim((string)($__lk['label'] ?? ''));
            $__lu = trim((string)($__lk['url'] ?? ''));
            if ($__lb !== '' && $__lu !== '') $__links[] = ['label' => $__lb, 'url' => $__lu];
        }
        if ($__title !== '' || $__links) $footerColumns[] = ['title' => $__title, 'links' => $__links];
    }
}
$footerLegalLinks = [];
$__flRaw = json_decode((string)($_settings['footer_legal_links'] ?? '[]'), true);
if (is_array($__flRaw)) {
    foreach ($__flRaw as $__lk) {
        if (!is_array($__lk)) continue;
        $__lb = trim((string)($__lk['label'] ?? ''));
        $__lu = trim((string)($__lk['url'] ?? ''));
        if ($__lb !== '' && $__lu !== '') $footerLegalLinks[] = ['label' => $__lb, 'url' => $__lu];
    }
}
/* PHASE_HC_FOOTER_BRAND — brand block + social links. Same defensive decode as the
 * columns above: a malformed value must never be able to break the page. */
$footerBrandEnabled = strtolower(trim((string)($_settings['footer_brand_enabled'] ?? 'off'))) === 'on';
$footerBrandPosition = strtolower(trim((string)($_settings['footer_brand_position'] ?? 'left'))) === 'right' ? 'right' : 'left';
$footerBrandLogo    = trim((string)($_settings['footer_brand_logo'] ?? ''));
$footerBrandTitle   = trim((string)($_settings['footer_brand_title'] ?? ''));
$footerBrandText    = trim((string)($_settings['footer_brand_text'] ?? ''));
$footerBrandAddress = trim((string)($_settings['footer_brand_address'] ?? ''));
$footerBrandPhone   = trim((string)($_settings['footer_brand_phone'] ?? ''));
/* tel: needs the dial string only — strip everything a dialler cannot use, but keep
 * a leading + for international numbers. Blank result = render as plain text. */
$__telDigits = preg_replace('/[^0-9+]/', '', $footerBrandPhone);
$footerBrandPhoneHref = (strlen(preg_replace('/[^0-9]/', '', (string)$__telDigits)) >= 6) ? (string)$__telDigits : '';
$footerSocial       = [];
$__fsRaw = json_decode((string)($_settings['footer_social'] ?? '[]'), true);
if (is_array($__fsRaw)) {
    foreach ($__fsRaw as $__sc) {
        if (!is_array($__sc)) continue;
        $__net = strtolower(trim((string)($__sc['network'] ?? '')));
        $__su  = trim((string)($__sc['url'] ?? ''));
        if ($__net !== '' && $__su !== '' && hc_social_icon($__net) !== '') {
            $footerSocial[] = ['network' => $__net, 'url' => $__su];
        }
    }
}
/* 'brand' only makes sense when the brand block renders; otherwise fall back so the
 * social links never silently disappear. */
$footerSocialPosition = strtolower(trim((string)($_settings['footer_social_position'] ?? 'copyright'))) === 'brand' ? 'brand' : 'copyright';
if ($footerSocialPosition === 'brand' && !$footerBrandEnabled) $footerSocialPosition = 'copyright';
/* PHASE_HC_FOOTER_BRAND — column-head treatment, copyright band surface, and how
 * much air sits between the two bands. */
$footerSocialStyle = strtolower(trim((string)($_settings['footer_social_style'] ?? 'chip'))) === 'plain' ? 'plain' : 'chip';
$__sSize = (int)($_settings['footer_social_size'] ?? 0);
$footerSocialSize = ($__sSize >= 12 && $__sSize <= 40) ? $__sSize : 17;
$__ctsRaw = strtolower(trim((string)($_settings['footer_col_title_style'] ?? 'plain')));
$footerColTitleStyle = in_array($__ctsRaw, ['plain','underline','bar','boxed'], true) ? $__ctsRaw : 'plain';
$__fspRaw = strtolower(trim((string)($_settings['footer_spacing'] ?? 'default')));
$footerSpacing = in_array($__fspRaw, ['compact','default','roomy'], true) ? $__fspRaw : 'default';
/* PHASE_HC_FOOTER_BRAND — payment badges (operator-supplied images). */
$footerPayment = [];
$__pmRaw = json_decode((string)($_settings['footer_payment'] ?? '[]'), true);
if (is_array($__pmRaw)) {
    foreach ($__pmRaw as $__pm) {
        if (!is_array($__pm)) continue;
        $__pi = trim((string)($__pm['image'] ?? ''));
        $__pl = trim((string)($__pm['label'] ?? ''));
        /* An icon-font class (what most sites already use for card marks) or an
         * image. Class is restricted to the safe charset so it cannot break out of
         * the attribute. */
        $__pc = trim((string)($__pm['icon'] ?? ''));
        if ($__pc !== '' && preg_match('~^[A-Za-z0-9 _-]{2,60}$~', $__pc)) {
            $footerPayment[] = ['label' => $__pl, 'icon' => $__pc, 'image' => ''];
            continue;
        }
        /* Only http(s) or a site-relative path — never javascript:/data: in an src. */
        if ($__pi !== '' && (preg_match('~^https?://~i', $__pi) || $__pi[0] === '/')) {
            $footerPayment[] = ['label' => $__pl, 'image' => $__pi, 'icon' => ''];
        }
    }
}
$footerPaymentPosition = strtolower(trim((string)($_settings['footer_payment_position'] ?? 'brand'))) === 'copyright' ? 'copyright' : 'brand';
/* Never more than five across — beyond that the plates shrink into noise. */
$__ppr = (int)($_settings['footer_payment_per_row'] ?? 4);
$footerPaymentPerRow = max(2, min(5, $__ppr > 0 ? $__ppr : 4));
$footerPaymentHover = strtolower(trim((string)($_settings['footer_payment_hover'] ?? 'on'))) !== 'off';
$footerPaymentStyle = strtolower(trim((string)($_settings['footer_payment_style'] ?? 'plate'))) === 'bare' ? 'bare' : 'plate';
$footerPaymentMobile = strtolower(trim((string)($_settings['footer_payment_mobile'] ?? 'bottom'))) === 'keep' ? 'keep' : 'bottom';
/* The whole strip in one card, rather than a plate behind each mark. On a phone
 * the strip already becomes a glass panel; this is that surface on every width,
 * and it composes with either badge style — plates inside a card, or bare marks
 * on the card alone. Its hover is a separate switch because a card that lifts is
 * a deliberate choice, not something a grouping toggle should decide for you. */
$footerPaymentCard      = strtolower(trim((string)($_settings['footer_payment_card'] ?? 'off'))) === 'on';
$footerPaymentCardHover = strtolower(trim((string)($_settings['footer_payment_card_hover'] ?? 'off'))) === 'on';
$__pSize = (int)($_settings['footer_payment_size'] ?? 0);
$footerPaymentSize = ($__pSize >= 12 && $__pSize <= 48) ? $__pSize : 22;
/* Plate colours. Anything that is not a plain hex is dropped rather than echoed
 * into a stylesheet. */
$__hex = static function ($v): string {
    $v = trim((string)$v);
    return preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $v) ? $v : '';
};
$footerPayPlateBg  = $__hex($_settings['footer_payment_plate_bg']  ?? '');
$footerPayPlateInk = $__hex($_settings['footer_payment_plate_ink'] ?? '');
$footerPayHoverBg  = $__hex($_settings['footer_payment_hover_bg']  ?? '');
$footerPayHoverInk = $__hex($_settings['footer_payment_hover_ink'] ?? '');
$footerPaymentNote = trim((string)($_settings['footer_payment_note'] ?? ''));
$__ctSize = (int)($_settings['footer_col_title_size'] ?? 0);
$footerColTitleSize = ($__ctSize >= 9 && $__ctSize <= 34) ? $__ctSize : 0;
$__lkSize = (int)($_settings['footer_link_size'] ?? 0);
$footerLinkSize = ($__lkSize >= 9 && $__lkSize <= 26) ? $__lkSize : 0;
$__lkGap = (int)($_settings['footer_link_gap'] ?? 0);
$footerLinkGap = ($__lkGap >= 0 && $__lkGap <= 40) ? $__lkGap : 0;
$__topGap = (int)($_settings['footer_top_gap'] ?? 0);
$footerTopGap = ($__topGap > 0 && $__topGap <= 200) ? $__topGap : 0;
$footerLegalPosition = strtolower(trim((string)($_settings['footer_legal_position'] ?? 'right'))) === 'left' ? 'left' : 'right';
$__lgSize = (int)($_settings['footer_legal_size'] ?? 0);
$footerLegalSize = ($__lgSize >= 9 && $__lgSize <= 22) ? $__lgSize : 0;
$footerLockOverscroll = strtolower(trim((string)($_settings['footer_lock_overscroll'] ?? 'on'))) !== 'off';
$footerMobileAccordion = strtolower(trim((string)($_settings['footer_mobile_accordion'] ?? 'on'))) !== 'off';
$footerMobileCols = ((string)($_settings['footer_mobile_cols'] ?? '1')) === '2' ? 2 : 1;
$footerCopyBgMode  = strtolower(trim((string)($_settings['footer_copy_bg_mode'] ?? 'inherit'))) === 'color' ? 'color' : 'inherit';
$footerCopyBgColor = trim((string)($_settings['footer_copy_bg_color'] ?? ''));
if (!preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $footerCopyBgColor)) { $footerCopyBgColor = ''; }
if ($footerCopyBgColor === '') $footerCopyBgMode = 'inherit';
$proto        = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$__originHost = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
$__originBase = $proto . '://' . $__originHost;      // where the app + its assets truly live

/* PHASE_HELP_PROXY — reverse-proxy awareness for own-domain hosting.
 * When a customer serves this help center under their OWN web address via a path
 * proxy (e.g. yourdomain.com/help → opsiqai.com/help/<slug>), the edge does not
 * preserve Host, so we still see opsiqai.com. It forwards the real public host in
 * X-Forwarded-Host (and, optionally, the public mount path in X-Forwarded-Prefix).
 * In that case every in-page LINK must point at the public host + mount, while
 * every ASSET stays absolute to the origin (see hc_asset_url). A CDN sitting in
 * front of opsiqai.com ITSELF sets X-Forwarded-Host = opsiqai.com (== Host), so it
 * never trips this — only a genuinely different public host does. */
$__fwdHost   = opsiq_help_fwd_host();
$__isProxied = ($__fwdHost !== '' && strcasecmp($__fwdHost, $__originHost) !== 0);
$__fwdProto  = strtolower(trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
$__pubProto  = in_array($__fwdProto, ['http', 'https'], true) ? $__fwdProto : $proto;
$__hasPrefix = array_key_exists('HTTP_X_FORWARDED_PREFIX', $_SERVER);
$__mount     = '/' . trim((string)preg_replace('~[^A-Za-z0-9/_\-]~', '', (string)($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? '')), '/');
/* No prefix header → default to /help (the shipped path-proxy guide). Header present
 * but empty or "/" → the operator serves the help center at the host ROOT (a dedicated
 * subdomain like help.yourdomain.com), so keep the root mount. */
if (!$__hasPrefix && $__mount === '/') $__mount = '/help';

$baseUrl     = $__isProxied ? ($__pubProto . '://' . $__fwdHost) : $__originBase;  // LINKS
$__assetBase = $__isProxied ? $__originBase : '';    // '' = leave asset paths relative (unchanged)
/* PHASE_PORTAL_P7.5 — on the branded portal host, all in-page links stay under
 * the pretty /hc/ path (workspace comes from the host, no slug needed) so
 * clicking an article never bounces the customer to /help.php. */
$helpBase   = $baseUrl . (!empty($__isPortalHelpHost) ? '/hc/' : '/help.php');
/* PHASE_HELP_SLUG — when the workspace was resolved from a /help.php/<slug>
 * path (or ?hslug=), every in-page link must keep that slug, otherwise clicking
 * through drops the workspace and falls back to the installed/default domain.
 * Self-hosted requests resolved purely by host carry no slug and stay bare. */
if (!empty($__hslug) && $_siteKey !== '') {
    /* PHASE_PORTAL_SLUG — rtrim first: the portal-help branch above ends in '/hc/', so a
     * bare append produced '/hc//<slug>'. It never showed because cloud pins the workspace
     * by host and carries no slug, but self-hosted /hc/<slug> makes both true at once. */
    $helpBase = rtrim($helpBase, '/') . '/' . rawurlencode($__hslug);
}

/* PHASE_HELP_PRETTY_URL — /help/<slug> is now the real, public address of a
 * cloud workspace's help center (a REAL directory, like /hc). Every in-page link
 * and the canonical use it.
 *
 * /help.php/<slug> KEEPS WORKING and is NOT redirected: customers have already
 * embedded and shared those URLs, and a 301 would break them. It simply points
 * its canonical at the pretty URL, so search engines consolidate on one address
 * instead of indexing the same help center twice. */
$_prettyHelp = '';
if (!empty($__hslug) && $_siteKey !== '' && empty($__isPortalHelpHost)
    && is_dir($_opsiqRoot . '/help')) {
    $_prettyHelp = '/help/' . rawurlencode($__hslug);
    $helpBase    = $baseUrl . $_prettyHelp;
}

/* PHASE_HELP_PROXY — behind a customer's path proxy the workspace slug lives in
 * the PROXY TARGET, not the public URL: visitors browse yourdomain.com/help, and
 * navigation is by query string (?cat=, ?article=, ?q=), so the mount alone is the
 * public base. Point links AND the canonical at <publicHost><mount> so clicking
 * never bounces to opsiqai.com and Google indexes the pages under the customer's
 * own domain. This also applies to a combined Portal host: its Worker forwards
 * /hc as the public mount while the internal target retains /hc/<workspace-slug>.
 * A direct branded Portal host is unaffected because it has no different
 * X-Forwarded-Host and therefore is not proxied here. */
if ($__isProxied) {
    $_prettyHelp = $__mount;
    $helpBase    = $baseUrl . $__mount;
}

/* Footer appearance */
$_footerBgModeRaw = strtolower(trim((string)($_settings['footer_bg_mode'] ?? 'color')));
$_footerBgMode = in_array($_footerBgModeRaw, ['none','color','gradient'], true) ? $_footerBgModeRaw : 'color';
$_footerColor = trim((string)($_settings['footer_bg_color'] ?? '#ffffff'));
$_footerColor = preg_match('/^#[0-9a-fA-F]{6}$/', $_footerColor) ? $_footerColor : '#ffffff';
$_footerGrad1 = trim((string)($_settings['footer_gradient_color_1'] ?? '#ffffff'));
$_footerGrad2 = trim((string)($_settings['footer_gradient_color_2'] ?? '#eef2ff'));
$_footerGrad3 = trim((string)($_settings['footer_gradient_color_3'] ?? ''));
$_footerGrad1 = preg_match('/^#[0-9a-fA-F]{6}$/', $_footerGrad1) ? $_footerGrad1 : '#ffffff';
$_footerGrad2 = preg_match('/^#[0-9a-fA-F]{6}$/', $_footerGrad2) ? $_footerGrad2 : '#eef2ff';
$_footerGrad3 = preg_match('/^#[0-9a-fA-F]{6}$/', $_footerGrad3) ? $_footerGrad3 : '';
$_fontStacks = [
    'system' => "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif",
    'inter' => "Inter,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif",
    'roboto' => "Roboto,'Helvetica Neue',Arial,sans-serif",
    'open_sans' => "'Open Sans','Helvetica Neue',Arial,sans-serif",
    'lato' => "Lato,'Helvetica Neue',Arial,sans-serif",
    'montserrat' => "Montserrat,'Helvetica Neue',Arial,sans-serif",
    'poppins' => "Poppins,'Helvetica Neue',Arial,sans-serif",
    'nunito' => "Nunito,'Helvetica Neue',Arial,sans-serif",
    'source_sans' => "'Source Sans 3','Helvetica Neue',Arial,sans-serif",
    'ubuntu' => "Ubuntu,'Helvetica Neue',Arial,sans-serif",
    'raleway' => "Raleway,'Helvetica Neue',Arial,sans-serif",
    'work_sans' => "'Work Sans','Helvetica Neue',Arial,sans-serif",
    'dm_sans' => "'DM Sans','Helvetica Neue',Arial,sans-serif",
    'manrope' => "Manrope,'Helvetica Neue',Arial,sans-serif",
    'rubik' => "Rubik,'Helvetica Neue',Arial,sans-serif",
    'mulish' => "Mulish,'Helvetica Neue',Arial,sans-serif",
    'plus_jakarta' => "'Plus Jakarta Sans','Helvetica Neue',Arial,sans-serif",
    'archivo' => "Archivo,'Helvetica Neue',Arial,sans-serif",
    'barlow' => "Barlow,'Helvetica Neue',Arial,sans-serif",
    'fira_sans' => "'Fira Sans','Helvetica Neue',Arial,sans-serif",
    'pt_sans' => "'PT Sans','Helvetica Neue',Arial,sans-serif",
    'merriweather' => "Merriweather,Georgia,serif",
    'playfair' => "'Playfair Display',Georgia,serif",
    'lora' => "Lora,Georgia,serif",
    'noto_serif' => "'Noto Serif',Georgia,serif",
    'bebas_neue' => "'Bebas Neue','Arial Narrow',sans-serif",
    'oswald' => "Oswald,'Arial Narrow',sans-serif",
    'inconsolata' => "Inconsolata,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace",
    'jetbrains_mono' => "'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace",
    'courier_prime' => "'Courier Prime',Courier,'Courier New',monospace",
];
$_globalFontRaw = trim((string)($_settings['font_family'] ?? ''));
$_globalFont = $_fontStacks[$_globalFontRaw] ?? "system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif";
$_globalFont = str_replace("'", '', $_globalFont);

$_footerFontRaw = trim((string)($_settings['footer_font_family'] ?? ''));
$_footerFont = $_footerFontRaw === '' ? 'inherit' : ($_fontStacks[$_footerFontRaw] ?? 'inherit');
if ($_footerFont !== 'inherit') $_footerFont = str_replace("'", '', $_footerFont);
$_footerFontSizeRaw = trim((string)($_settings['footer_font_size'] ?? ''));
$_footerFontSize = 'inherit';
if ($_footerFontSizeRaw !== '') {
    $_footerFontSizeInt = (int)$_footerFontSizeRaw;
    if ($_footerFontSizeInt >= 10 && $_footerFontSizeInt <= 28) $_footerFontSize = $_footerFontSizeInt . 'px';
}
$_footerFontWeightRaw = trim((string)($_settings['footer_font_weight'] ?? ''));
$_footerFontWeight = 'inherit';
if ($_footerFontWeightRaw !== '') {
    $_fw = (int)$_footerFontWeightRaw;
    if (in_array($_fw, [300, 400, 500, 600, 700, 800, 900], true)) $_footerFontWeight = (string)$_fw;
}
$_footerBackground = 'transparent';
if ($_footerBgMode === 'color') {
    $_footerBackground = $_footerColor;
} elseif ($_footerBgMode === 'gradient') {
    $_gradStops = $_footerGrad3 !== '' ? ($_footerGrad1 . ',' . $_footerGrad2 . ',' . $_footerGrad3) : ($_footerGrad1 . ',' . $_footerGrad2);
    $_footerBackground = 'linear-gradient(135deg,' . $_gradStops . ')';
}

/* PHASE_HC_FOOTER_BRAND — is the chosen footer background DARK? The footer's type
 * inherits --text-muted / --text-secondary, which are tuned for the light page
 * behind it; drop a dark colour in and the copyright and column titles land at
 * roughly #475569 on #1a1a2e, i.e. about 1.9:1 and effectively unreadable. Rather
 * than make the operator hand-pick three more colours, derive it: average the
 * gradient stops' relative luminance and flip the footer's own text vars when the
 * result is dark. The per-element Design Studio colours are emitted later and with
 * !important, so an explicit choice still wins over this default. */
$__footDark = false;
$__hcLum = static function (string $hex): ?float {
    $hex = ltrim(trim($hex), '#');
    if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
    if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return null;
    $c = [];
    foreach ([0, 2, 4] as $i) {
        $v = hexdec(substr($hex, $i, 2)) / 255;
        $c[] = $v <= 0.04045 ? $v / 12.92 : pow(($v + 0.055) / 1.055, 2.4);
    }
    return 0.2126 * $c[0] + 0.7152 * $c[1] + 0.0722 * $c[2];
};
$__footStops = [];
if ($_footerBgMode === 'color') {
    $__footStops = [$_footerColor];
} elseif ($_footerBgMode === 'gradient') {
    $__footStops = array_filter([$_footerGrad1, $_footerGrad2, $_footerGrad3], static fn($s) => trim((string)$s) !== '');
}
$__footLums = [];
foreach ($__footStops as $__st) {
    $__l = $__hcLum((string)$__st);
    if ($__l !== null) $__footLums[] = $__l;
}
if ($__footLums) $__footDark = (array_sum($__footLums) / count($__footLums)) < 0.22;

/* Layout */
$_layoutRaw = strtolower(trim((string)($_settings['layout'] ?? 'nebula')));
$_validLayouts = ['nebula','aurora','minimal','obsidian','prism','classic','atlas','editorial','command','horizon','vault','orbit','mosaic','zenith','runway','ledger','sonar','gallery','stack','sanctuary'];
$_layout = in_array($_layoutRaw, $_validLayouts, true) ? $_layoutRaw : 'nebula';
// Preview override (visual only, never persisted) so admins/preview iframes can audit a layout.
if (!empty($_GET['hclay'])) {
    $_hclayReq = strtolower(trim((string)$_GET['hclay']));
    if (in_array($_hclayReq, $_validLayouts, true)) $_layout = $_hclayReq;
}

$_surfaceRaw = strtolower(trim((string)($_settings['surface_style'] ?? 'crystal')));
$_validSurfaces = ['crystal','smoke','pearl','solid'];
$_surfaceStyle = in_array($_surfaceRaw, $_validSurfaces, true) ? $_surfaceRaw : 'crystal';

/* PHASE_HC_ICON_STUDIO — category-icon tile treatment (Colours → Category icons).
 * Resolved here at global scope, alongside every other style token, because the
 * markup is emitted inside hc_build_view() — which imports these globals but NOT
 * $_settings. 'soft' is the original look, so no class is added for it. */
$_iconStyleRaw = strtolower(trim((string)($_settings['icon_style'] ?? 'soft')));
$_validIconStyles = ['soft','solid','gradient','outline','glass','bare'];
$_iconStyle = in_array($_iconStyleRaw, $_validIconStyles, true) ? $_iconStyleRaw : 'soft';
$_iconStyleCls = $_iconStyle !== 'soft' ? ' hc-ico-' . $_iconStyle : '';

$_heroShapeRaw = strtolower(trim((string)($_settings['hero_shape'] ?? 'cinematic')));
$_validHeroShapes = ['cinematic','compact','split'];
$_heroShape = in_array($_heroShapeRaw, $_validHeroShapes, true) ? $_heroShapeRaw : 'cinematic';

$_cardStyleRaw = strtolower(trim((string)($_settings['card_style'] ?? 'lifted')));
$_validCardStyles = ['lifted','bordered','soft'];
$_cardStyle = in_array($_cardStyleRaw, $_validCardStyles, true) ? $_cardStyleRaw : 'lifted';
/* PHASE_HC_MORE_CONFIG — article listing card LAYOUT (list rows | boxed cards |
 * media cards with per-article image). Distinct from the surface finish above. */
$_articleCardStyleRaw = strtolower(trim((string)($_settings['article_card_style'] ?? 'list')));
/* PHASE10J_2026-08-11 — five NEW presentations join the seven; the five thin
 * ones (list/timeline/compact/feature/split rendered as near-identical rows)
 * each got a real design of their own. */
/* PHASE_HC_CHRONICLE_CARD_2026-08-24 — `chronicle` joins them: the timeline's date
 * rail with every entry on the CARD surface instead of straight on the page. */
$articleCardStyle = in_array($_articleCardStyleRaw, ['list','boxed','media','timeline','chronicle','compact','feature','split','stub','showcase','glass','ledger','rail','mosaic'], true) ? $_articleCardStyleRaw : 'list';
/* PHASE_HC_HOME_LAYOUT — home page lists categories (directory pattern) vs
 * the current popular-articles feed. In categories mode the home page has NO
 * category side-nav; opening a category shows a side-nav of that category's
 * articles. `$homeCategoryIcons` shows/hides the big centred tile icon. */
/* PHASE_HC_HOME_IA — 'categories' IS the shipped default (HelpCenter::$defaults
 * and opsiq/hc_default_settings.php both say so). Every fallback in this file
 * said 'articles', which only ever mattered when the key was missing outright —
 * but it meant the code disagreed with its own default, and the admin dropdown
 * labelled the wrong option "(default)". Aligned. */
$homeLayout = strtolower(trim((string)($_settings['home_layout'] ?? 'categories'))) === 'articles' ? 'articles' : 'categories';
$homeCategoryIcons = strtolower(trim((string)($_settings['home_category_icons'] ?? 'show'))) !== 'hide';
$homeCategoryStyle = strtolower(trim((string)($_settings['home_category_style'] ?? 'card')));
if (!in_array($homeCategoryStyle, ['card','badge','minimal','glow','list','bare','plain','detail'], true)) $homeCategoryStyle = 'card';
/* PHASE_HC_CAT_TINT — 'theme' tints the tiles with the brand colour (not white). */
$homeCategoryTint = strtolower(trim((string)($_settings['home_category_tint'] ?? 'plain'))) === 'theme' ? 'theme' : 'plain';
$homeCategoryIconTint = strtolower(trim((string)($_settings['home_category_icon_tint'] ?? 'default'))) === 'theme' ? 'theme' : 'default';

/* PHASE_HC_DARK — one dark palette for every layout. Resolved here at global scope
 * (hc_build_view() cannot see $_settings) and consumed by the dark CSS block. */
$darkEnabled = !in_array(strtolower((string)($_settings['dark_enabled'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
$darkDefault = strtolower(trim((string)($_settings['dark_default'] ?? 'auto')));
if (!in_array($darkDefault, ['auto', 'light', 'dark'], true)) $darkDefault = 'auto';
$darkToggleNav = !in_array(strtolower((string)($_settings['dark_toggle_in_nav'] ?? 'on')), ['0', 'off', 'false', 'no'], true);
/* Operator overrides; blank keeps the built-in. A bad value is ignored rather than
 * emitted, because this lands in a stylesheet. */
$__hexOk = static function ($v): string {
    $v = trim((string)$v);
    return preg_match('/^#[0-9a-fA-F]{3,8}$/', $v) ? $v : '';
};
$darkBg      = $__hexOk($_settings['dark_bg'] ?? '')      ?: '#0b0f16';
$darkSurface = $__hexOk($_settings['dark_surface'] ?? '') ?: '#151b25';
$darkInk     = $__hexOk($_settings['dark_ink'] ?? '')     ?: '#e8edf5';
/* A saved dark ink can be syntactically valid and still be unreadable. Keep the
 * operator value only when it clears AA against the dark card; otherwise use the
 * shipped neutral. This is Help Center theme safety, independent of Portal /HC. */
$__liftedDarkInk = HelpCenter::darkBrandInk($darkInk, $darkSurface);
if ($__liftedDarkInk !== '' && strtolower($__liftedDarkInk) !== strtolower($darkInk)) {
    $darkInk = '#e8edf5';
}

/* PHASE_HC_WIDGET_STUDIO — "Open in help center", shown at the foot of an article
 * IN THE PANEL only. Resolved here at global scope because hc_build_view() cannot
 * see $_settings, and every one of these has to be in that function's global list. */
$widgetHelpLink      = !in_array(strtolower((string)($_settings['widget_help_link'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
$widgetHelpLinkMode  = strtolower(trim((string)($_settings['widget_help_link_mode'] ?? 'normal'))) === 'own' ? 'own' : 'normal';
$widgetHelpLinkUrl   = trim((string)($_settings['widget_help_link_url'] ?? ''));
/* `open_in_hc` has been declared and translated into 39 languages the whole time; this
 * call site simply never reached for it, so the fallback shipped English everywhere. */
$widgetHelpLinkLabel = trim((string)($_settings['widget_help_link_label'] ?? '')) ?: $__t('open_in_hc', 'Open in help center');
/* PHASE_HC_HOME_FEATURED — a titled block of featured (★) articles on the home,
 * alongside the category tiles. Opt-in; renamable title; above/below the tiles. */
$homeFeaturedEnabled  = filter_var($_settings['home_featured_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
/* PHASE_HC_I18N — translates when it is still the shipped English word. */
$homeFeaturedTitle    = $__titleTxt('home_featured_title', 'Featured', 'featured');
$homeFeaturedPosition = strtolower(trim((string)($_settings['home_featured_position'] ?? 'above'))) === 'below' ? 'below' : 'above';
$homeFeaturedLimit    = max(1, min(24, (int)($_settings['home_featured_limit'] ?? 6)));
$homeFeaturedSource   = strtolower(trim((string)($_settings['home_featured_source'] ?? 'featured')));
if (!in_array($homeFeaturedSource, ['featured','popular','latest'], true)) $homeFeaturedSource = 'featured';

/* ── NEWS AND UPDATES ────────────────────────────────────────────────────────
 * An optional home section, wired exactly like the featured row above so the two
 * behave the same way (own switch, own heading, above/below the categories).
 *
 * What makes it NEWS and not just another article list is where it draws from:
 * the News and Announcement categories ONLY, never the whole knowledge base. The
 * two feeds are merged fairly rather than concatenated, so a busy Announcements
 * category cannot crowd News out of the section entirely. */
$homeNewsEnabled  = filter_var($_settings['home_news_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$homeNewsTitle    = $__titleTxt('home_news_title', 'News and updates', 'news');
$homeNewsPosition = strtolower(trim((string)($_settings['home_news_position'] ?? 'below'))) === 'above' ? 'above' : 'below';
$homeNewsLimit    = max(1, min(30, (int)($_settings['home_news_limit'] ?? 6)));
$homeNewsColumns  = max(1, min(3, (int)($_settings['home_news_columns'] ?? 2)));
$homeNewsFeed     = strtolower(trim((string)($_settings['home_news_feed'] ?? 'both')));
if (!in_array($homeNewsFeed, ['both','news','announcement'], true)) $homeNewsFeed = 'both';
$homeNewsShowDate = !isset($_settings['home_news_show_date']) || filter_var($_settings['home_news_show_date'], FILTER_VALIDATE_BOOLEAN);
$homeNewsShowCat  = !isset($_settings['home_news_show_cat'])  || filter_var($_settings['home_news_show_cat'],  FILTER_VALIDATE_BOOLEAN);
$homeNewsViewAll  = trim((string)($_settings['home_news_view_all'] ?? ''));

/* ── QUICK LINKS ─────────────────────────────────────────────────────────────
 * Operator-authored shortcuts, not knowledge-base rows: a label, a URL, and an
 * optional icon and one-line description. Stored as JSON in one setting so the
 * whole list travels together. */
$homeQuickEnabled  = filter_var($_settings['home_quick_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$homeQuickTitle    = $__titleTxt('home_quick_title', 'Quick links', 'quick_links');
$homeQuickPosition = strtolower(trim((string)($_settings['home_quick_position'] ?? 'below'))) === 'above' ? 'above' : 'below';
$homeQuickStyle    = (function($v){ /* PHASE5_2026-08-06 — was a tabs/tiles binary; three more
     * presentations of the same tab rail were added. Unknown values still fall
     * back to tiles, so an old blob or a hand-edit cannot render an unstyled shell. */
    /* WITHDRAWN 2026-08-07 — icons/pills/boxed were added as "variants" of this block.
     * They were not variants of anything: this renderer implements exactly two things,
     * the auto-topic tab rail and the manual tile grid. A third name simply fell through
     * to tiles. Owner: "your quick links can't live with that quick links, yours needs
     * its own controls." Correct. This block is left exactly as it was. */
    /* PHASE9c_2026-08-09 — six more, each with its own branch in hc_render_quick().
     * The withdrawal above is the reason every one of these was added as a RENDERER
     * first and an enum value second. Unknown values still fall back to tiles. */
    return in_array($v, ['tabs', 'tiles', 'rows', 'inline', 'numbered', 'columns', 'marquee', 'split'], true) ? $v : 'tiles';
  })(strtolower(trim((string)($_settings['home_quick_style'] ?? 'tiles'))));
$homeQuickLimit    = max(1, min(20, (int)($_settings['home_quick_limit'] ?? 6)));
$homeQuickDisplay  = strtolower(trim((string)($_settings['home_quick_display'] ?? 'grouped'))) === 'flat' ? 'flat' : 'grouped';
$homeQuickItems    = [];
if (($__qRaw = trim((string)($_settings['home_quick_items'] ?? ''))) !== '') {
    $__qDec = json_decode($__qRaw, true);
    if (is_array($__qDec)) {
        foreach ($__qDec as $__qi) {
            if (!is_array($__qi)) continue;
            $__lbl = trim((string)($__qi['label'] ?? ''));
            if ($__lbl === '') continue;
            /* PHASE_HC_QUICK_SCHEMA_2026-08-15 — ONE ROW, BOTH SHAPES.
             *
             * A quick-link row drives two different presentations and this line used to
             * carry only one of them:
             *
             *   tabs           needs label + source + category + limit  (a TOPIC that
             *                  resolves to a list of articles)
             *   the other 7    need label + url + desc                  (a LINK)
             *
             * Only `url`/`desc` survived here, and the Studio's editor only ever wrote
             * `source`/`category`/`limit`. So the two halves never met:
             *
             *   - every non-tabs design gated its anchor on a url that could not be
             *     authored, and rendered text a visitor could see and not click;
             *   - the tab resolver at the $_homeQuickTabs block below reads
             *     $q['source'], ['category'], ['limit'] and ['url'] off THESE rows, so
             *     with all four dropped here every tab silently fell through to `auto`
             *     no matter which source the operator chose.
             *
             * Carrying the whole row fixes both from one place. Unknown keys stay out:
             * this is the row contract, not a passthrough. */
            $homeQuickItems[] = [
                'label'    => $__lbl,
                'url'      => trim((string)($__qi['url'] ?? '')),
                'icon'     => trim((string)($__qi['icon'] ?? '')),
                'desc'     => trim((string)($__qi['desc'] ?? '')),
                'source'   => trim((string)($__qi['source'] ?? '')),
                'category' => trim((string)($__qi['category'] ?? '')),
                'limit'    => max(0, (int)($__qi['limit'] ?? 0)),
            ];
        }
    }
}
/* ── THE CTA BAND ────────────────────────────────────────────────────────────
 * Ten templates, one band. Title / subhead / body come from textareas and keep
 * their newlines, because a line break in a headline is an authoring decision,
 * not an accident of container width. */
$homeCtaEnabled  = filter_var($_settings['home_cta_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$homeCtaPosition = strtolower(trim((string)($_settings['home_cta_position'] ?? 'below'))) === 'above' ? 'above' : 'below';
$__ctaEnum = static function (string $key, array $allowed, string $def) use ($_settings): string {
    $v = strtolower(trim((string)($_settings[$key] ?? $def)));
    return in_array($v, $allowed, true) ? $v : $def;
};
$homeCtaVariant = $__ctaEnum('home_cta_variant', ['prompt','contact','split','gradient','fullbleed','command','editorial','glass','framed','decision'], 'prompt');
$homeCtaSize    = $__ctaEnum('home_cta_size',   ['sm','md','lg'], 'md');
$homeCtaWidth   = $__ctaEnum('home_cta_width',  ['small','medium','large','full'], 'medium');
$homeCtaAlign   = $__ctaEnum('home_cta_align',  ['left','center'], 'left');
$homeCtaRadius  = $__ctaEnum('home_cta_radius', ['inherit','none','sm','md','lg','xl','pill'], 'inherit');
$homeCtaShadow  = $__ctaEnum('home_cta_shadow', ['inherit','none','sm','md','lg','xl'], 'inherit');
$homeCtaHeight  = $__ctaEnum('home_cta_height', ['auto','short','medium','tall','hero'], 'auto');
$homeCtaSides   = $__ctaEnum('home_cta_sides',  ['auto','none','sm','md','lg','xl'], 'auto');
$homeCta = [
    'eyebrow' => trim((string)($_settings['home_cta_eyebrow'] ?? '')),
    'title'   => trim((string)($_settings['home_cta_title'] ?? '')),
    'subhead' => trim((string)($_settings['home_cta_subhead'] ?? '')),
    'body'    => trim((string)($_settings['home_cta_body'] ?? '')),
    'note'    => trim((string)($_settings['home_cta_note'] ?? '')),
    'badge'   => trim((string)($_settings['home_cta_badge'] ?? '')),
    'icon'    => trim((string)($_settings['home_cta_icon'] ?? '')),
    'image'   => trim((string)($_settings['home_cta_image'] ?? '')),
    'p_label' => trim((string)($_settings['home_cta_primary_label'] ?? '')),
    'p_url'   => trim((string)($_settings['home_cta_primary_url'] ?? '')),
    's_label' => trim((string)($_settings['home_cta_secondary_label'] ?? '')),
    's_url'   => trim((string)($_settings['home_cta_secondary_url'] ?? '')),
];
/* PHASE10K8_2026-08-11 — the per-surface CTA map. Resolved ONCE here, at global
 * scope, and read from $GLOBALS by hc_cta_slot(): hc_build_view() and the render
 * helpers never import $_settings, so a naked read inside them is null and the
 * whole component silently switches off. withLegacy() folds the older flat keys
 * The K6 flat keys (reader_cta, category_cta and their wording) were deleted on
 * 2026-08-15: none had a control, so no operator could ever set one. The live
 * workspaces that already use them keep rendering while the component owns the
 * behaviour from here on. */
$GLOBALS['__hcCtaSurfaces'] = \OpsIQ\Kb\HcCta::resolve($_settings);
/* Heading above the home category tiles (default "Resources"; blank = no heading). */
$homeCategoriesTitle  = $__titleTxt('home_categories_title', 'Resources', 'resources');

/* PHASE_HC_SUBCAT_STYLE — child categories used to render through hc_cat_card()
 * (the .hc-cat list component), which is NOT the tile system the home grid uses,
 * so they came out undesigned next to their parent. They now go through the SAME
 * tile renderer, with their OWN style + icon choice so a compact child grid can
 * sit under a big home grid. Defaults mirror the home tiles.
 *
 * $homeCategoryLimit caps how many tiles the HOME page shows. 0 = no limit.
 *
 * NOTE: these are resolved HERE, at global scope, because hc_build_view() only
 * imports named globals — a read of $_settings inside it silently yields null.  */
$homeCategoryLimit = max(0, (int)($_settings['home_category_limit'] ?? 0));
$subcategoryIcons  = strtolower(trim((string)($_settings['subcategory_icons'] ?? 'show'))) !== 'hide';
$subcategoryStyle  = strtolower(trim((string)($_settings['subcategory_style'] ?? 'card')));
if (!in_array($subcategoryStyle, ['card','badge','minimal','glow','list','bare','plain'], true)) $subcategoryStyle = 'card';

$_densityRaw = strtolower(trim((string)($_settings['content_density'] ?? 'comfortable')));
$_validDensities = ['airy','comfortable','compact'];
$_contentDensity = in_array($_densityRaw, $_validDensities, true) ? $_densityRaw : 'comfortable';

$_motionRaw = strtolower(trim((string)($_settings['motion_intensity'] ?? 'premium')));
$_motionIntensity = in_array($_motionRaw, ['calm','premium','dramatic'], true) ? $_motionRaw : 'premium';
$_depthRaw = strtolower(trim((string)($_settings['visual_depth'] ?? 'deep')));
$_visualDepth = in_array($_depthRaw, ['flat','deep','extreme'], true) ? $_depthRaw : 'deep';
$_radiusRaw = strtolower(trim((string)($_settings['radius_style'] ?? 'sculpted')));
$_radiusStyle = in_array($_radiusRaw, ['sharp','soft','sculpted'], true) ? $_radiusRaw : 'sculpted';

$_showHeaderNav = (bool)($_settings['show_header_nav'] ?? true);
$_navStyleRaw = strtolower(trim((string)($_settings['nav_style'] ?? 'glassbar')));
$_validNavStyles = ['glassbar','solid','dark','floating','split','sidebar'];
$_navStyle = in_array($_navStyleRaw, $_validNavStyles, true) ? $_navStyleRaw : 'glassbar';
/* PHASE_HC_NAV_BUILD2 — dropdown menu presentation preset. */
$_navMenuStyleRaw = strtolower(trim((string)($_settings['nav_menu_style'] ?? 'dropdown')));
$_navMenuStyle = in_array($_navMenuStyleRaw, ['dropdown','mega','simple'], true) ? $_navMenuStyleRaw : 'dropdown';
/* PHASE_HC_NAV_REFINE — dropdown caret icon + per-group mega layout. */
$_navCaretRaw = strtolower(trim((string)($_settings['nav_caret'] ?? 'caret')));
$_navCaret = in_array($_navCaretRaw, ['caret','chevron','none'], true) ? $_navCaretRaw : 'caret';
$_navLogoPositionRaw = strtolower(trim((string)($_settings['nav_logo_position'] ?? 'left')));
$_navLogoPosition = in_array($_navLogoPositionRaw, ['left','right','center'], true) ? $_navLogoPositionRaw : 'left';
/* PHASE_HC_NAV_LINKS_ALIGN_2026-08-27 — where the MENU LINKS sit in the header.
 *
 * Owner: *"the nav links need settings to align the menu left right center, excluding CTA
 * buttons, language and theme toggle."*
 *
 * It lands on `.hc-nav-links` alone, which is exactly the exclusion asked for and needs no
 * enumeration to achieve it: the language picker, the theme toggle and the CTA buttons are
 * SIBLINGS of that element inside `.hc-nav-collapse`, not children of it, so they keep the
 * end of the bar wherever the links go.
 *
 * `auto` — the default — emits nothing, so the nav style keeps deciding: `hc-nav-split`
 * ends the links right, `hc-logo-right` starts them left, `hc-nav-sidebar` stacks them.
 * Only an explicit choice overrides that, and it does so from `#hc-hdr` + a class, (1,2,0),
 * because every one of those rules is (0,2,0) and would otherwise win on order. */
$_navLinksAlignRaw = strtolower(trim((string)($_settings['nav_links_align'] ?? 'auto')));
$_navLinksAlign = in_array($_navLinksAlignRaw, ['auto','left','center','right'], true) ? $_navLinksAlignRaw : 'auto';
$_navBgModeRaw = strtolower(trim((string)($_settings['nav_bg_mode'] ?? 'default')));
$_navBgMode = in_array($_navBgModeRaw, ['default','solid','gradient'], true) ? $_navBgModeRaw : 'default';
$_navBgColor = trim((string)($_settings['nav_bg_color'] ?? ''));
$_navTextColor = trim((string)($_settings['nav_text_color'] ?? ''));
$_navHoverColor = trim((string)($_settings['nav_hover_color'] ?? ''));
$_navBgColor = preg_match('/^#[0-9a-fA-F]{6}$/', $_navBgColor) ? $_navBgColor : '';
$_navTextColor = preg_match('/^#[0-9a-fA-F]{6}$/', $_navTextColor) ? $_navTextColor : '';
$_navHoverColor = preg_match('/^#[0-9a-fA-F]{6}$/', $_navHoverColor) ? $_navHoverColor : '';
$_navGradCount = max(2, min(5, (int)($_settings['nav_gradient_count'] ?? 2)));
$_navGradColors = [];
for ($_ngi = 1; $_ngi <= 5; $_ngi++) {
    $_navGradHex = trim((string)($_settings['nav_gradient_color_' . $_ngi] ?? ''));
    if ($_navGradHex !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $_navGradHex)) $_navGradColors[] = $_navGradHex;
}
if (count($_navGradColors) < 2) {
    $_navGradColors = ['#111827', '#334155'];
}
if (count($_navGradColors) > $_navGradCount) {
    $_navGradColors = array_slice($_navGradColors, 0, $_navGradCount);
}
$_navGradientBg = 'linear-gradient(135deg,' . implode(',', $_navGradColors) . ')';
$_navResolvedBg = 'transparent';
if ($_navBgMode === 'solid' && $_navBgColor !== '') {
    $_navResolvedBg = $_navBgColor;
} elseif ($_navBgMode === 'gradient') {
    $_navResolvedBg = $_navGradientBg;
}
/* PHASE_HC_NAV_ITEMS_2026-08-14 — the navigation MODEL. One JSON key holding the
 * portal's item shape; `type` (link|dropdown|mega) belongs to the item rather than
 * being matched against a separate list of names, and a mega item carries its OWN
 * promo cards. Replaces `nav_links` + `nav_mega_groups`. See OpsIQ\Kb\HcNav. */
$_navItems = \OpsIQ\Kb\HcNav::decode($_settings['nav_items'] ?? '');
$_navCtaLabel = trim((string)($_settings['nav_cta_label'] ?? ''));
$_navCtaUrl = trim((string)($_settings['nav_cta_url'] ?? ''));
/* PHASE_HC_NAV_BUILD3 — multiple CTAs + size. nav_ctas (JSON) supersedes the
 * legacy single pair; falls back to it for backward compatibility. */
$_navCtas = [];
$__navCtaRaw = trim((string)($_settings['nav_ctas'] ?? ''));
if ($__navCtaRaw !== '') {
    $__dec = json_decode($__navCtaRaw, true);
    if (is_array($__dec)) {
        foreach ($__dec as $c) {
            if (!is_array($c)) continue;
            $l = trim((string)($c['label'] ?? '')); $u = trim((string)($c['url'] ?? ''));
            if ($l === '' || $u === '') continue;
            $st = in_array(($c['style'] ?? 'primary'), ['primary','outline','ghost'], true) ? (string)$c['style'] : 'primary';
            $_navCtas[] = ['label' => $l, 'url' => $u, 'style' => $st];
        }
    }
}
if (!$_navCtas && $_navCtaLabel !== '' && $_navCtaUrl !== '') {
    $_navCtas[] = ['label' => $_navCtaLabel, 'url' => $_navCtaUrl, 'style' => 'primary'];
}
$__navCtaSizeRaw = strtolower(trim((string)($_settings['nav_cta_size'] ?? 'medium')));
$_navCtaSize = in_array($__navCtaSizeRaw, ['small','medium','large'], true) ? $__navCtaSizeRaw : 'medium';
$_navFontSize = (float)($_settings['nav_font_size'] ?? 0);
if ($_navFontSize > 0) $_navFontSize = max(10, min(22, $_navFontSize));
$_navFontFamilyRaw = trim((string)($_settings['nav_font_family'] ?? ''));
$_navFont = ($_navFontFamilyRaw !== '' && isset($_fontStacks[$_navFontFamilyRaw])) ? str_replace("'", '', $_fontStacks[$_navFontFamilyRaw]) : '';

/* PHASE_HC_CUSTOM_FONT — any Google Font / CDN font. When a font is set to
 * 'custom', use the custom family; and (importantly) actually LOAD the selected
 * webfonts, which the built-in picker never did. */
/* A font CDN value may be a full URL (https://…), a protocol-relative //… or a
 * root/relative path (/…, ../…) to a self-hosted CSS. Reject anything else. */
$__fontUrlOk = fn($u) => ($u !== '' && preg_match('~^(https?://|//|/|\.\.?/)~', $u)) ? $u : '';
/* Turn a family entry into a usable CSS stack: a full stack (has a comma) is
 * used as-is; a bare name gets sensible fallbacks. Trailing ";" is stripped. */
$__fontStack = function ($fam) {
    $fam = trim(rtrim(trim((string)$fam), '; '));
    if ($fam === '') return '';
    return (strpos($fam, ',') !== false) ? $fam
        : (str_replace(['"'], '', $fam) . ",system-ui,-apple-system,'Segoe UI',Roboto,sans-serif");
};

$_fontCustomUrl    = $__fontUrlOk(trim((string)($_settings['font_custom_url'] ?? '')));
$_fontCustomFamily = trim((string)($_settings['font_custom_family'] ?? ''));
/* No family given → auto-detect from a Google Fonts URL (…family=Raleway → Raleway). */
if ($_fontCustomFamily === '' && $_fontCustomUrl !== '' && preg_match('~[?&]family=([^:&|@]+)~i', $_fontCustomUrl, $__fm)) {
    $_fontCustomFamily = trim(str_replace('+', ' ', urldecode($__fm[1])));
}
$__customStack = $__fontStack($_fontCustomFamily);
if ($_globalFontRaw === 'custom' && $__customStack !== '') $_globalFont = str_replace("'", '', $__customStack);
if ($_navFontFamilyRaw === 'custom' && $__customStack !== '') $_navFont = str_replace("'", '', $__customStack);
if ($_footerFontRaw === 'custom' && $__customStack !== '') $_footerFont = str_replace("'", '', $__customStack);

/* Separate Headings font (h1/h2/h3 + titles). Blank family → headings use the
 * main font (via the CSS var fallback). */
$_headingFontUrl    = $__fontUrlOk(trim((string)($_settings['heading_font_url'] ?? '')));
$_headingFontFamily = trim((string)($_settings['heading_font_family'] ?? ''));
if ($_headingFontFamily === '' && $_headingFontUrl !== '' && preg_match('~[?&]family=([^:&|@]+)~i', $_headingFontUrl, $__hm)) {
    $_headingFontFamily = trim(str_replace('+', ' ', urldecode($__hm[1])));
}
$_headingFont = str_replace("'", '', $__fontStack($_headingFontFamily));   // '' → inherit main font

/* Collect the built-in webfonts in use and load them from Google Fonts. */
$_fontFamiliesToLoad = [];
foreach ([$_globalFontRaw, $_footerFontRaw, $_navFontFamilyRaw] as $__fk) {
    if ($__fk === '' || $__fk === 'system' || $__fk === 'custom' || !isset($_fontStacks[$__fk])) continue;
    $__fam = trim(explode(',', $_fontStacks[$__fk])[0], " '\"");
    if ($__fam !== '' && stripos($__fam, 'system') === false) $_fontFamiliesToLoad[$__fam] = true;
}
$_fontHeadLinks = '';
if ($_fontFamiliesToLoad) {
    $__fams = array_map(fn($f) => str_replace(' ', '+', $f) . ':300,400,500,600,700,800,900', array_keys($_fontFamiliesToLoad));
    $_fontHeadLinks .= '<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
                    .  '<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=' . hc_esc(implode('|', $__fams)) . '&display=swap">';
}
foreach ([$_fontCustomUrl, $_headingFontUrl] as $__cu) {
    if ($__cu !== '') $_fontHeadLinks .= '<link rel="stylesheet" href="' . hc_esc($__cu) . '">';
}

/* Text overrides — fall back to the built-in default whenever the saved value
 * is BLANK. getSettings() always sets these keys (to '' by default) and a blank
 * field saves as '', so `?? default` never fires on an empty string — the hero
 * heading/subtitle/etc. would render empty instead of the default. Treat any
 * blank (empty-after-trim) value as "use the built-in default". */
$txtHeroHeading     = $__txt('text_hero_heading',       'How can we help you?');
/* PHASE_HC_HERO_SEARCH_WIDTH — the CONFIGURED hero heading, kept untouched.
 * Subpages overwrite $txtHeroHeading with the category/article title before
 * rendering the hero (hc_build_view), and because the hero's content column
 * shrink-wraps to its widest line, that shorter title used to shrink the search
 * bar with it. hc_hero_sizer() re-inserts this original heading as an invisible,
 * zero-height line inside the h1, so the column measures the same on every page
 * and the search bar keeps its home-page width — without touching any theme's
 * hero design. */
$_heroHeadingBase   = $txtHeroHeading;
$txtHeroSub         = $__txt('text_hero_sub',           'Search our knowledge base or browse categories below');
/* Same story for the sub: some themes (runway) keep the h1 OUTSIDE the panel
 * that holds the search, so there the widest line is the SUBTITLE — and subpages
 * swap it for "N articles in this category". Both lines get a sizer. */
$_heroSubBase       = $txtHeroSub;
$txtSearchPH        = $__txt('text_search_placeholder', 'Search for articles…');
$txtBrowseLabel     = $__txt('text_browse_label',       'Browse by Category');
$txtPopularLabel    = $__txt('text_popular_label',      'Popular Articles');
$txtNoResults       = $__txt('text_no_results',         'No results found. Try different keywords.');
$txtFeedbackQ       = $__txt('text_feedback_q',         'Was this article helpful?');
$txtContactPrompt   = $__txt('text_contact_prompt',     'Still need help? Contact us');
$txtContactUrl      = (string)($_settings['text_contact_url']        ?? '');

$__sizeToCss = static function (float $v): string {
    $n = rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.');
    return ($n === '' ? '0' : $n) . 'px';
};

$__resolveTextSize = static function (string $key, float $min, float $max) use ($_settings, $__sizeToCss): string {
    $raw = trim((string)($_settings[$key] ?? ''));
    if ($raw === '' || !is_numeric($raw)) return '';
    $v = (float)$raw;
    if ($v < $min) $v = $min;
    if ($v > $max) $v = $max;
    return $__sizeToCss($v);
};

$_txtFsHeroHeading = $__resolveTextSize('text_hero_heading_size', 24, 140);
$_txtFsHeroSub = $__resolveTextSize('text_hero_sub_size', 11, 42);
$_txtFsSearchPh = $__resolveTextSize('text_search_placeholder_size', 11, 40);
/* PHASE0.5_2026-08-09 — the search BUTTON's label size. Twelve sibling text sizes are
 * resolved here and this one was missing, so the control saved a value and the button
 * never changed. Found by scanning which settings keys have a reader, not by reading
 * this list — the gap is invisible when the line simply is not there. */
$_txtFsSearchBtn = $__resolveTextSize('text_search_button_size', 11, 28);
$_txtFsBrowseLabel = $__resolveTextSize('text_browse_label_size', 10, 32);
$_txtFsPopularLabel = $__resolveTextSize('text_popular_label_size', 10, 32);
/* PHASE10K2_2026-08-11 — the two newest bands had no size control at all. */
$_txtFsFaqLabel      = $__resolveTextSize('text_faq_label_size', 10, 32);
/* PHASE10K7_2026-08-11 — the labels the audit found with NO size control:
 * the links band heading (whatever the operator names it — "Popular right
 * now" on the live site), the sidebar card titles ("On this page", "Related
 * articles") and the sidebar help card's title. */
$_txtFsLinksLabel    = $__resolveTextSize('hc_links_title_size', 10, 40);
$_txtFsSideTitle     = $__resolveTextSize('text_side_title_size', 9, 30);
$_txtFsHelpCardTitle = $__resolveTextSize('sidebar_help_card_title_size', 10, 32);
$_txtFsFeaturedLabel = $__resolveTextSize('home_featured_title_size', 10, 32);
/* The two home sections' headings are sized from the Typography tab like every
 * other piece of copy. Blank = the design's own size. */
$_txtFsNewsLabel  = $__resolveTextSize('home_news_title_size', 12, 40);
$_txtFsQuickLabel = $__resolveTextSize('home_quick_title_size', 10, 32);
$_txtFsCatsLabel = $__resolveTextSize('home_categories_title_size', 10, 32);
$_txtFsNoResults = $__resolveTextSize('text_no_results_size', 12, 42);
$_txtFsFeedbackQ = $__resolveTextSize('text_feedback_q_size', 11, 32);
$_txtFsContactPrompt = $__resolveTextSize('text_contact_prompt_size', 10, 32);

$_textColorOverrideEnabled = !empty($_settings['text_color_override_enabled']);
$_textColorModeRaw = strtolower(trim((string)($_settings['text_color_mode'] ?? 'normal')));
$_textColorMode = in_array($_textColorModeRaw, ['normal','gradient'], true) ? $_textColorModeRaw : 'normal';
$_textColorNormal = trim((string)($_settings['text_color_normal'] ?? ''));
$_textColorNormal = preg_match('/^#[0-9a-fA-F]{6}$/', $_textColorNormal) ? $_textColorNormal : '';
$_textGradCount = max(2, min(5, (int)($_settings['text_gradient_count'] ?? 2)));
$_textGradColors = [];
for ($_tgi = 1; $_tgi <= 5; $_tgi++) {
    $_tgc = trim((string)($_settings['text_gradient_color_' . $_tgi] ?? ''));
    if ($_tgc !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $_tgc)) $_textGradColors[] = $_tgc;
}
if (count($_textGradColors) < 2) {
    $_textGradColors = ['#6366f1', '#06b6d4'];
}
if (count($_textGradColors) > $_textGradCount) {
    $_textGradColors = array_slice($_textGradColors, 0, $_textGradCount);
}
$_textGradientCss = 'linear-gradient(135deg,' . implode(',', $_textGradColors) . ')';

/* PHASE_HC_HERO_LEGACY_CUT_2026-08-17 — the legacy show_hero / show_subpage_hero pair is
 * GONE. Since PHASE1 the per-surface hero_<surface> modes have been the authority and the
 * pair survived only as an "inherit" fallback for a workspace with no mode set. On every
 * real workspace all four modes are set, so the two Studio toggles that wrote the pair
 * auto-saved ("Toggle saved.") and changed NOTHING — and could contradict the page:
 * show_subpage_hero='1' while hero_article='none' showed the article header ON in the
 * Studio and OFF on the page. Pre-launch: no back-compat, the pair is removed everywhere.
 * An unset mode now inherits the same defaults the old pair produced when both were on. */
/* PHASE1_2026-08-06 — PER-SURFACE hero mode.
 *
 * The two booleans above could not express what operators asked for. They were ORed
 * on category pages, so switching the big hero off there was impossible without also
 * killing the article subhero; and a category page could never show the small hero at
 * all, because $showCategorySubhero was hardcoded false and its renderer branch was
 * unreachable. Both are fixed below.
 *
 * Each surface now answers for itself. '' means inherit the legacy pair, which is what
 * every existing workspace holds, so this changes NOTHING until an operator picks a
 * mode — the inherit values below reproduce exactly what each surface renders today.
 *
 *   full     the full landing hero, all 20 themes           (not offered on articles)
 *   subhero  the compact page header with breadcrumbs
 *   search   a slim band carrying only the search field
 *   none     no header band at all
 */
$__heroModes = [
    'home'     => ['full', 'none'],
    'category' => ['full', 'subhero', 'search', 'none'],
    'article'  => ['subhero', 'search', 'none'],
    'search'   => ['full', 'subhero', 'search', 'none'],
];
$__heroMode = static function (string $surface) use ($_settings, $__heroModes): string {
    $v = trim((string)($_settings['hero_' . $surface] ?? ''));
    if ($v !== '' && in_array($v, $__heroModes[$surface], true)) return $v;
    /* Unset → the historical "both on" rendering, per surface. */
    return $surface === 'article' ? 'subhero' : 'full';
};
/* PHASE2_2026-08-06 — the side rail, per surface. It used to be unconditional; its
 * only control was which side it sat on. Owner: "option to enable and disable side
 * bar for categories or article page or reading page". */
$__sbBool = static fn(string $k, bool $d) => !in_array(strtolower((string)($_settings[$k] ?? ($d ? '1' : ''))), ['', '0', 'off', 'false', 'no'], true);
$sidebarCategory = $__sbBool('sidebar_category', true);
$sidebarArticle  = $__sbBool('sidebar_article', true);
$sidebarSearch   = $__sbBool('sidebar_search', true);
/* What the CATEGORY rail lists. 'tree' is the new default and fixes the audit's
 * duplication finding: in categories mode the rail listed the SAME category's
 * articles the main column was already showing, so the page printed one list twice.
 * The old behaviour is still available as 'articles' for anyone who wants it. */
$sidebarCatContent = (string)($_settings['sidebar_category_content'] ?? 'tree');

$heroModeHome     = $__heroMode('home');
/* The browse page is a category surface as far as chrome is concerned. */
if (!empty($_isBrowse)) $heroModeHome = $__heroMode('category');
$heroModeCategory = $__heroMode('category');
$heroModeArticle  = $__heroMode('article');
$heroModeSearch   = $__heroMode('search');
$showStats         = (bool)($_settings['show_stats']         ?? false);
$showCategories    = (bool)($_settings['show_categories']    ?? true);
$showPopular       = (bool)($_settings['show_popular']       ?? true);
$popularArticlesLimit = max(1, min(24, (int)($_settings['popular_articles_limit'] ?? 8)));
$showFeedback      = (bool)($_settings['show_feedback']      ?? true);
$showContactPrompt = (bool)($_settings['show_contact_prompt'] ?? false);
$showToc           = (bool)($_settings['show_toc']           ?? true);
$showRelated       = (bool)($_settings['show_related']       ?? true);
$showFooter        = (bool)($_settings['show_footer']        ?? true);

/* ── PHASE_HC_SECTIONS — per-section visibility + display rules ──────────────
 *
 * Every section of the help center is configurable: show it, hide it, show all
 * items, show a set number, show only the latest, show only featured, or apply a
 * custom rule (sort + tag filter). The rules resolve on top of the legacy
 * show_* toggles above, so an install that never opens the new controls behaves
 * exactly as it did before.
 *
 * hc_rule('x') returns the rule; hc_rule_apply() filters/sorts/caps a list and
 * reports what it hid, so a cap can offer "show all" instead of silently eating
 * content. */
$_sectionRules = class_exists('\\OpsIQ\\Kb\\HcSections')
    ? \OpsIQ\Kb\HcSections::rules($_settings)
    : [];

function hc_rule(string $section): array {
    global $_sectionRules;
    return $_sectionRules[$section] ?? ['visible' => true, 'mode' => 'all', 'limit' => 0, 'sort' => 'default', 'tag' => '', 'more' => true];
}
function hc_shows(string $section): bool {
    return !empty(hc_rule($section)['visible']);
}
function hc_rule_apply(array $items, string $section, string $kind = 'articles'): array {
    if (!class_exists('\\OpsIQ\\Kb\\HcSections')) {
        return ['items' => $items, 'total' => count($items), 'hidden' => 0, 'source' => count($items)];
    }
    return \OpsIQ\Kb\HcSections::apply($items, hc_rule($section), $kind);
}

/* The legacy toggles now READ THROUGH the rules, so the new controls actually
 * govern the page and the old ones keep working when nothing has been saved. */
$showStats         = hc_shows('stats');
$showCategories    = hc_shows('home_categories');
$showPopular       = hc_shows('home_articles');
$showFeedback      = hc_shows('feedback');
$showContactPrompt = hc_shows('contact_prompt');
$showToc           = hc_shows('toc');
$showRelated       = hc_shows('related_articles');
$showFooter        = hc_shows('footer');

/* Like/dislike display. Nobody prints a bare dislike count (it is social proof
 * against your own article), and "0 out of 0 found this helpful" on a fresh
 * article is the classic way this looks broken — hence the minimum. */
$feedbackCounts    = strtolower(trim((string)($_settings['feedback_counts'] ?? 'hide')));
if (!in_array($feedbackCounts, ['hide','ratio','counts'], true)) $feedbackCounts = 'hide';
$feedbackCountsMin = max(0, (int)($_settings['feedback_counts_min'] ?? 3));

$categorySidebarStyleRaw = strtolower(trim((string)($_settings['category_sidebar_style'] ?? 'cards')));
$categorySidebarStyle = in_array($categorySidebarStyleRaw, ['cards','compact','pill','bordered','flat','floating','command','contextual','documentation','editorial','drawer'], true) ? $categorySidebarStyleRaw : 'cards';
/* PHASE10K32_2026-08-13 — how thin the sidebar's own scrollbar is. Default 'thin'
 * is 2px in Chromium; Firefox takes keywords only, so it gets `thin` there and
 * 'hidden' is the only way to go below that in Gecko. */
$categorySidebarScroll = strtolower(trim((string)($_settings['sidebar_scrollbar'] ?? 'thin')));
if (!in_array($categorySidebarScroll, ['thin','hidden','standard'], true)) $categorySidebarScroll = 'thin';
$readerLayoutRaw = strtolower(trim((string)($_settings['reader_layout'] ?? 'standard')));
$readerLayout = in_array($readerLayoutRaw, ['standard','documentation','resolution','editorial','reference','policy','focus','steps','magazine','terminal'], true) ? $readerLayoutRaw : 'standard';
/* PHASE_HC_MORE_CONFIG — which side the category browse rail sits on. */
$categorySidebarPosition = strtolower(trim((string)($_settings['category_sidebar_position'] ?? 'right'))) === 'left' ? 'left' : 'right';
/* PHASE_HC_MORE_CONFIG — floating back-to-top button side. */
$backToTopPosition = strtolower(trim((string)($_settings['back_to_top_position'] ?? 'right'))) === 'left' ? 'left' : 'right';
/* PHASE_HC_TYPOGRAPHY — article reading-page main-title font size (px); 0 = theme default. */
$articleTitleSize = (int)($_settings['article_title_size'] ?? 0);
/* PHASE_HC_MOBILE_WIDGET — on phones, give the CATEGORY and ARTICLE pages the
 * widget panel's compact single-column layout. The home page is excluded: its
 * tiles already read well small, and the owner asked for it to stay as-is. */
$mobileWidgetView = strtolower(trim((string)($_settings['mobile_widget_view'] ?? 'on'))) !== 'off';

/* Custom code (public + embed). */
$customCss = trim((string)($_settings['custom_css'] ?? ''));
$customJs  = trim((string)($_settings['custom_js'] ?? ''));
if (class_exists('\\OpsIQ\\Security\\CustomCodeGovernance')) {
    $customJs = \OpsIQ\Security\CustomCodeGovernance::effectiveCode((string)$_siteKey, 'help_center', $customJs);
    /* HELP_CUSTOM_CODE_SPLIT_2026-09-12 — CSS and JavaScript are independent
     * executable surfaces. Sharing `help_center` meant the first lookup adopted
     * JavaScript as the active revision and the CSS lookup then returned those
     * same bytes. The browser consequently received JavaScript inside the
     * hc-custom-css style element and every operator override disappeared. */
    $customCss = \OpsIQ\Security\CustomCodeGovernance::effectiveCode((string)$_siteKey, 'help_center_css', $customCss, 'css');
}
if ($customCss !== '') {
    $customCss = preg_replace('/<\/?style[^>]*>/i', '', $customCss) ?? $customCss;
    $customCss = str_ireplace('</style', '<\/style', $customCss);
}
if ($customJs !== '') {
    $customJs = preg_replace('/<\/?script[^>]*>/i', '', $customJs) ?? $customJs;
    $customJs = str_ireplace('</script', '<\/script', $customJs);
}

/* PHASE_HC_DEAD_SETTING_REMOVED_2026-08-17 — `show_search_header` is gone.
 * Header search is off by product decision (navigation stays clean; search lives on the
 * hero and search surfaces). The setting was still REGISTERED, defaulted, and written as a
 * literal 0 by the Studio collector on every save — advertising a choice that could not
 * exist, and rewriting the stored key from panes that had nothing to do with it. The
 * variable it fed was assigned here and never read anywhere, so both are removed rather
 * than left as a decoy. Pre-launch: no back-compat shim. */

/* ── hero_bg + btn_color ──────────────────────────────────────────────────── */
$heroBg   = preg_replace('/[^#a-fA-F0-9]/', '', (string)($_settings['hero_bg']   ?? '')) ?: '';
$btnColor = preg_replace('/[^#a-fA-F0-9]/', '', (string)($_settings['btn_color'] ?? '')) ?: $brandColor;
/* PHASE_HC_MORE_CONFIG — optional hero background IMAGE + darkening overlay. */
$heroBgImage = trim((string)($_settings['hero_bg_image'] ?? ''));
if ($heroBgImage !== '' && !preg_match('~^(https?://|/)~i', $heroBgImage)) $heroBgImage = '';  // only same-origin or absolute URLs
$heroOverlay = max(0, min(90, (int)($_settings['hero_bg_overlay'] ?? 55))) / 100;

/* PHASE_HC_SUBHERO_IMAGE_2026-08-27 — the same three for the article/category header.
 * Owner: *"just like hero has use image, sub hero should have where to upload image for
 * it too."* Same URL guard as the hero: only same-origin or absolute, so a stored value
 * can never become a `javascript:` or `data:` url inside a CSS url(). */
$subheroBgImage = trim((string)($_settings['subhero_bg_image'] ?? ''));
if ($subheroBgImage !== '' && !preg_match('~^(https?://|/)~i', $subheroBgImage)) $subheroBgImage = '';
$subheroOverlay = max(0, min(90, (int)($_settings['subhero_bg_overlay'] ?? 55))) / 100;

/* PHASE_PORTAL_P7.5 — UNIFORMITY: on the branded portal host, the help center
 * inherits the Portal Design Studio's palette so it matches the ticket portal
 * (one design source, two surfaces). Colours + fonts come from the published
 * portal design; the gradient/accent surface drives the hero + buttons. */
$__portalDesignBundle = null;
if (!empty($__isPortalHelpHost) && !empty($__isPortalHcPath)) {
    if (!function_exists('opsiq_portal_design_get') && is_file($_opsiqRoot . '/opsiq/opsiq.portal_design.php')) {
        require_once $_opsiqRoot . '/opsiq/opsiq.portal_design.php';
    }
    if (function_exists('opsiq_portal_design_get')) {
        try {
            $__pd = opsiq_portal_design_get(true);
            /* LOCALISE THE DESIGN ITSELF, NOT JUST THE BUNDLE'S FRAGMENTS
             * (15 September 2026).
             *
             * $__pd is read directly by hc_render_hero(), which calls
             * opsiq_portal_render_hero($__pd) and opsiq_portal_render_search($__pd)
             * — so the hero heading, its subheading, the search placeholder and the
             * search button all rendered from the UN-translated design however well
             * the rest of the page was translated. "We are here to help" stayed
             * English under a fully French nav.
             *
             * Doing it here, on the one design every consumer shares, fixes the hero
             * and anything else that reads $__pd without each having to know about
             * locales. opsiq_portal_localized_design() is the same resolver the
             * bundle uses, it returns the design unchanged when there is no overlay
             * or no workspace, and the source locale is skipped because for it the
             * design already IS the translation. */
            if ($_locale !== $_i18nSource && function_exists('opsiq_portal_localized_design')) {
                try { $__pd = opsiq_portal_localized_design($__pd, $_locale, (string)$_siteKey); }
                catch (\Throwable $e) { /* a language failure must never blank the page */ }
            }
            $__pt = is_array($__pd['theme'] ?? null) ? $__pd['theme'] : [];
            /* Accent: the gradient-surface system is the real source (its solid
             * base), falling back to the legacy flat theme key — reading only
             * the legacy key gave #111 while the portal renders purple. */
            $__pAccent = '';
            if (function_exists('opsiq_portal_surface_base')) {
                $__pAccent = preg_replace('/[^#a-fA-F0-9]/', '',
                    (string)opsiq_portal_surface_base(is_array($__pt['surfaces']['accent'] ?? null) ? $__pt['surfaces']['accent'] : [], ''));
            }
            if ($__pAccent === '') $__pAccent = preg_replace('/[^#a-fA-F0-9]/', '', (string)($__pt['accent'] ?? ''));
            if ($__pAccent !== '') { $brandColor = $__pAccent; $btnColor = $__pAccent; }
            /* Hero band: a solid portal hero surface maps to the help hero base;
             * a gradient is handled by the studio bundle CSS injected below. */
            if (function_exists('opsiq_portal_surface_base')) {
                $__pHero = opsiq_portal_surface_base(is_array($__pt['surfaces']['hero'] ?? null) ? $__pt['surfaces']['hero'] : [], '');
                if ($__pHero !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $__pHero)) $heroBg = $__pHero;
            }
            if (function_exists('opsiq_portal_design_bundle')) {
                $__portalDesignBundle = opsiq_portal_design_bundle($__pd);
                /* THE TRANSLATED NAV AND FOOTER WERE BUILT AND THEN IGNORED
                 * (15 September 2026).
                 *
                 * The owner runs the portal nav and footer on both surfaces ("use
                 * the portal nav everywhere"), so these two fragments ARE the
                 * customer-facing chrome of the Help Centre. The bundle renders them
                 * twice: once from the base design, which is in the workspace's
                 * SOURCE language, and once per requested locale under
                 * localized_designs — and this file only ever read the first. On
                 * support.nabtech.co that put a French Help Centre under an English
                 * nav and an English footer, with a perfectly good 11,705-byte
                 * French nav sitting unread in the same response.
                 *
                 * Merged here, once, so every consumer downstream (the unified nav
                 * and footer flags below, the announcement, the sign-in and request
                 * pages) gets the visitor's language without each having to know
                 * about it. Only non-empty strings replace anything, and the source
                 * locale is skipped because for it the base render already IS the
                 * translation. */
                if (is_array($__portalDesignBundle) && $_locale !== $_i18nSource) {
                    $__pdFrag = $__portalDesignBundle['localized_designs'][$_locale] ?? null;
                    if (is_array($__pdFrag)) {
                        foreach ($__pdFrag as $__fk => $__fv) {
                            if (is_string($__fv) && trim($__fv) !== '') $__portalDesignBundle[$__fk] = $__fv;
                        }
                    }
                }
            }
        } catch (\Throwable $e) {}
    }
}
/* Unified = on the branded host AND the "match my portal design" toggle is on
 * (default ON). Off → the help center keeps its own standalone design. */
/* Public page: opsiq_portal_setting (admin module) is not loaded here, so read
 * the workspace-scoped row directly. Keys are stored as site:<site_key>:<key>
 * with a bare legacy fallback — the site-scoped row must win (SettingsStore
 * default-site clobber gotcha). */
if (!function_exists('opsiq_help_portal_setting')) {
    function opsiq_help_portal_setting(string $key, string $default, string $siteKey): string {
        /* PHASE_HC_HERO_MATCH — the toggle is WORKSPACE-SCOPED. opsiq_portal_setting()
         * takes no site key: it resolves against the ambient/default workspace, which
         * on a public help host is not necessarily the workspace that owns the host.
         * So whenever we know the host's site key, read that workspace's row directly
         * and only fall back to the ambient reader when we have no key at all. */
        if ($siteKey === '' && function_exists('opsiq_portal_setting')) {
            return (string)opsiq_portal_setting($key, $default);
        }
        try {
            $scoped = 'site:' . $siteKey . ':' . $key;
            $rows = \Illuminate\Database\Capsule\Manager::table('opsiq_settings')
                ->whereIn('setting', [$scoped, $key])->pluck('value', 'setting');
            if (isset($rows[$scoped])) return (string)$rows[$scoped];
            if (isset($rows[$key])) return (string)$rows[$key];
        } catch (\Throwable $e) {}
        return $default;
    }
}
/* Default OFF. Matching the help center to the portal is opt-in: it only happens once an
 * operator ticks the toggle for that workspace. A workspace that HAS turned it on keeps it
 * on, because an explicit saved row always beats this fallback. */
$__helpUnifyToggle = in_array(
    opsiq_help_portal_setting('portal_help_unified', '0', (string)$_siteKey),
    ['1', 'on', 'true'], true);
$__helpUnified = !empty($__isPortalHelpHost) && !empty($__isPortalHcPath)
    && is_array($__portalDesignBundle) && $__helpUnifyToggle;
$__helpHeroMode = $__helpUnified
    ? opsiq_help_portal_setting('portal_help_hero', 'each', (string)$_siteKey)
    : 'each';
if (!in_array($__helpHeroMode, ['each', 'hc', 'portal'], true)) $__helpHeroMode = 'each';
/* PHASE_PORTAL_TWO_WAY_HERO — either surface may now own the shared hero. `hc`
 * is mounted by portal.php; `portal` is rendered here through the one shared
 * hero factory, which makes it independent of all twenty Help Center themes. */
$__usePortalHero = $__helpUnified && $__helpHeroMode === 'portal';
/* PHASE_HC_CHROME_SOURCE — nav and footer are chosen INDEPENDENTLY.
 * Matching used to be all-or-nothing: turn it on and the help centre wore the portal's nav
 * AND footer. Operators want to mix — e.g. the portal's nav for one continuous header, but
 * the help centre's own richer footer. Two settings, each defaulting to 'portal' so existing
 * installs are untouched. With matching OFF both fall back to the help centre's own chrome. */
$__helpNavMode = $__helpUnified
    ? (string)opsiq_help_portal_setting('portal_help_nav', 'portal', (string)$_siteKey) : 'hc';
if (!in_array($__helpNavMode, ['portal', 'hc'], true)) $__helpNavMode = 'portal';
$__helpFootMode = $__helpUnified
    ? (string)opsiq_help_portal_setting('portal_help_footer', 'portal', (string)$_siteKey) : 'hc';
if (!in_array($__helpFootMode, ['portal', 'hc'], true)) $__helpFootMode = 'portal';

$__useUnifiedNav = $__helpUnified && $__helpNavMode === 'portal' && !empty($__portalDesignBundle['nav_html']);
$__useUnifiedFooter = $__helpUnified && $__helpFootMode === 'portal' && !empty($__portalDesignBundle['footer_html']);
/* Miro-style section label in the unified nav: brand | HELP CENTER. Editable
 * text (Settings → Support Portal); empty string hides the label entirely. */
$__helpNavLabel = $__useUnifiedNav
    ? trim(opsiq_help_portal_setting('portal_help_nav_label', 'Help Center', (string)$_siteKey)) : '';
/* OPTEXT_NAV_LABEL (15 September 2026) — the label is the operator's OWN words, so it is
 * the Languages page that holds its translations, not the Help Center service: HcTranslator
 * translates the chrome WE ship and the articles they write, and it has never had anything
 * to say about a workspace setting. Without this the unified nav read "brand | HELP CENTER"
 * in English on a Help Centre a visitor had just switched to Japanese.
 * A locale with no translation keeps the operator's own text. */
if ($__helpNavLabel !== '' && $_locale !== '' && function_exists('opsiq_email_static_text')) {
    try { $__helpNavLabel = trim((string)opsiq_email_static_text('portal_help_nav_label', $_locale, $__helpNavLabel)); }
    catch (\Throwable $e) { /* keep the operator's own words */ }
}

/* PHASE_HC_DESIGN_STUDIO — the studio's Brand colour (ds_brand) is the single
 * source of truth for the accent when set; it drives --brand / --br everywhere.
 * A gradient falls back to its first stop (an accent must be a solid colour). */
$__dsBrand = hc_ds_color((string)($_settings['ds_brand'] ?? ''));
/* On the unified branded host the PORTAL accent is the source of truth for
 * --brand/--br (hovers, active items, focus rings all derive from them) —
 * the help center's own studio brand must not clobber it back. */
if ($__dsBrand !== '' && empty($__helpUnified)) {
    if (strncmp($__dsBrand, 'linear-gradient', 15) === 0 && preg_match('/#[0-9a-fA-F]{3,8}/', $__dsBrand, $__m)) $__dsBrand = $__m[0];
    if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $__dsBrand)) { $brandColor = $__dsBrand; $btnColor = $__dsBrand; }
}
/* ── Brand colour derivation for CSS custom properties ───────────────────── */
$__hex = ltrim($brandColor, '#');
if (strlen($__hex) === 3) $__hex = $__hex[0].$__hex[0].$__hex[1].$__hex[1].$__hex[2].$__hex[2];
$__r = (int)hexdec(substr($__hex, 0, 2));
$__g = (int)hexdec(substr($__hex, 2, 2));
$__b = (int)hexdec(substr($__hex, 4, 2));
$brandRGB = "$__r,$__g,$__b";

/* PHASE10K13_2026-08-12 — THE TEXT THAT SITS ON THE BRAND.
 *
 * Eleven components fill a shape with the brand and print text on top: the
 * letter plate in the split listing, the platform number, the masthead kicker,
 * the index chip. Every one of them assumed WHITE, which is only true for a
 * dark brand. A workspace whose brand is amber, lime or pale grey — and
 * white-labelling is the whole point of this product — got white text on a
 * light fill and could not read its own cards.
 *
 * Derived from the brand's relative luminance (WCAG 2.1 §1.4.3), so it follows
 * whatever colour a workspace sets and needs no setting of its own. The
 * crossover at L=0.179 is where white and near-black have equal contrast
 * against the fill, so the token always picks the more legible of the two.
 */
$__lin = static function (int $c): float {
    $s = $c / 255;
    return $s <= 0.03928 ? $s / 12.92 : (float) pow(($s + 0.055) / 1.055, 2.4);
};
$__brandLum = 0.2126 * $__lin($__r) + 0.7152 * $__lin($__g) + 0.0722 * $__lin($__b);
$brandOn    = $__brandLum > 0.1791 ? '#0f172a' : '#ffffff';

/* PHASE0.5_2026-08-09 — THE BRAND AS TEXT ON A DARK SURFACE.
 *
 * Every other token is remapped for dark; --brand never was, so the operator's brand is
 * used verbatim on a dark card. Measured on the shipped purple: brand-coloured links and
 * titles land at 3.56 against the dark card — fine for large text (AA 3.0), a fail for
 * body text (AA 4.5). Eleven elements on one page.
 *
 * It CANNOT be fixed by lightening --brand itself. Eleven rules use it as a BACKGROUND
 * with white text on top, and white-on-brand goes 4.86 -> 2.85 the moment the fill
 * lightens. So the fill keeps the real brand and text gets its own token.
 *
 * Derived, not hardcoded, so it follows whatever brand a workspace sets: step the colour
 * toward white until it clears AA against that workspace's own dark surface, and stop.
 * Falls back to --brand in light mode, where the token is never declared.
 */
$__brandInk = HelpCenter::darkBrandInk($brandColor, $darkSurface);
/* An explicit choice always wins over the derivation. Validated as a hex like every
 * other palette token, so a typo falls back to the derived tone rather than emitting
 * a broken declaration. */
$__brandInkSet = $__hexOk($_settings['dark_brand_ink'] ?? '');
if ($__brandInkSet !== '') $__brandInk = $__brandInkSet;

/* ── Button colour RGB ───────────────────────────────────────────────────── */
$__btnHex = ltrim($btnColor, '#');
if (strlen($__btnHex) === 3) $__btnHex = $__btnHex[0].$__btnHex[0].$__btnHex[1].$__btnHex[1].$__btnHex[2].$__btnHex[2];
$__btnR = (int)hexdec(substr($__btnHex, 0, 2));
$__btnG = (int)hexdec(substr($__btnHex, 2, 2));
$__btnB = (int)hexdec(substr($__btnHex, 4, 2));
$btnRGB = "$__btnR,$__btnG,$__btnB";

/* Hero base color */
$heroBase = $heroBg ?: '#08081c';

/* ── isHome: home view OR search (hero stays visible on search) ──────────── */
$_isNotFound = (!$_article && $_articleSlug !== "") || (!$_category && $_catSlug !== "");
$_isHome   = (!$_article && ($_catSlug === "" || $_isNotFound));
/* PHASE9e_2026-08-10 — THE BROWSE SURFACE.
 *
 * ?cats=all used to re-render the whole home page with every category appended, so a
 * reader who asked to see the categories got news, quick links, popular articles and 111
 * categories in one scroll. In 'page' mode it is now its own surface: the categories and
 * nothing else.
 *
 * It borrows the CATEGORY surface's hero and sidebar settings rather than growing its
 * own pair — an operator who has decided how a category page looks has already answered
 * the same question for this page. */
$_isBrowse = (!$_article && $_catSlug === '' && isset($_GET['cats']) && $_GET['cats'] === 'all'
              && strtolower(trim((string)($_settings['cats_more_action'] ?? 'page'))) !== 'expand');
$GLOBALS['_isBrowse'] = $_isBrowse;
$_viewType = $_article ? "article" : ($_isNotFound ? "not-found" : ($_catSlug !== "" ? "category" : ($_query !== "" ? "search" : "home")));

$pageTitle = $siteName;
if ($_article)      $pageTitle = htmlspecialchars((string)($_article['page_title'] ?? $__t("article", "Article"))) . ' — ' . $siteName;
elseif ($_category) $pageTitle = htmlspecialchars((string)$_category['name']) . ' — ' . $siteName;
elseif ($_articleSlug !== "") $pageTitle = $__t("article_not_found", "Article not found") . " — " . $siteName;
elseif ($_catSlug !== "") $pageTitle = $__t("category_not_found", "Category not found") . " — " . $siteName;
elseif ($_query !== '') $pageTitle = $__t("search", "Search") . ': ' . htmlspecialchars($_query) . ' — ' . $siteName;

/* ── Reading time ─────────────────────────────────────────────────────────── */
$_readTime = 0;
if ($_article) {
    $__wc = str_word_count(strip_tags((string)($_article['content_text'] ?? '')));
    $_readTime = max(1, (int)ceil($__wc / 200));
}

/* ── Home hero stats ──────────────────────────────────────────────────────── */
$_totalArticles = (int)array_sum(array_column($_categories, 'article_count'));
$_totalCats     = count($_categories);

/* ── Year for footer ──────────────────────────────────────────────────────── */
$_currentYear = (int)date('Y');

function hc_esc(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }

/* PHASE_HC_DATE_I18N_2026-08-15 — DATES WERE ENGLISH IN EVERY LANGUAGE.
 * `date('j M Y')` emits "14 Jul 2026" whatever the visitor's locale is: PHP's date()
 * month names are not localisable, full stop. On the French home page the news list and
 * the article "last updated" line both read "14 Jul 2026" — identical to English, next to
 * fully translated titles. ext-intl is present on this server, so IntlDateFormatter does
 * it properly (and handles the locales where the day/month ORDER differs, which no amount
 * of month-name substitution would). Falls back to date() if intl ever goes missing, so a
 * page can never fatal over a date. */
function hc_date_local(int $ts, string $pattern = 'd MMM y'): string {
    if ($ts <= 0) return '';
    $loc = (string)($GLOBALS['_locale'] ?? 'en');
    if (class_exists('IntlDateFormatter')) {
        try {
            $f = new IntlDateFormatter($loc ?: 'en', IntlDateFormatter::NONE, IntlDateFormatter::NONE);
            $f->setPattern($pattern);
            $out = (string)$f->format($ts);
            if ($out !== '') return $out;
        } catch (\Throwable $e) { /* fall through to the ASCII date below */ }
    }
    return date('j M Y', $ts);
}

/* PHASE_HC_LISTING_I18N_2026-08-15 — the news list printed a TRANSLATED article title and
 * an ENGLISH category beside it ("Annonces - Nabtech" … "Announcements"), because the rows
 * carry a denormalised `category_name` string that no overlay ever touches. Mapping by the
 * English name rather than the id keeps this usable from any row shape. Built once. */
function hc_cat_name_local(string $englishName): string {
    static $map = null;
    if ($englishName === '') return '';
    if ($map === null) {
        $map = [];
        try {
            $loc = (string)($GLOBALS['_locale'] ?? '');
            $sk  = (string)($GLOBALS['_siteKey'] ?? '');
            if ($loc !== '' && $sk !== '' && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
                $cats = (array)\OpsIQ\Kb\HelpCenter::listCategories($sk);
                $en   = [];
                foreach ($cats as $c) { $c = (array)$c; $en[] = trim((string)($c['name'] ?? '')); }
                \OpsIQ\Kb\HcTranslator::overlayCategories($cats, $sk, $loc);
                foreach (array_values($cats) as $i => $c) {
                    $c = (array)$c;
                    $srcName = $en[$i] ?? '';
                    $trName  = trim((string)($c['name'] ?? ''));
                    if ($srcName !== '' && $trName !== '') $map[$srcName] = $trName;
                }
            }
        } catch (\Throwable $e) { $map = []; }
    }
    return $map[$englishName] ?? $englishName;
}

/* PHASE_HC_FOOTER_BRAND — social glyphs for the footer. Inline SVG on purpose: the
 * help center must never depend on an icon CDN (see the self-hosted feather fix), and
 * these also have to survive inside the widget iframe with no network of its own.
 * Returns '' for an unknown network, which is what gates the saved list — an
 * operator cannot store a network we cannot draw. */
function hc_social_icon(string $net): string {
    static $icons = null;
    if ($icons === null) {
        $icons = [
            'facebook'  => '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
            'x'         => '<path d="M18.9 2H22l-7.4 8.4L23 22h-6.9l-5.4-7-6.2 7H1.4l7.9-9L1 2h7l4.9 6.4zm-1.2 18h1.9L7.4 3.9H5.4z"/>',
            /* Outline glyph — see hc_social_stroke(): filled, this became a solid square. */
            'instagram' => '<rect x="2.5" y="2.5" width="19" height="19" rx="5.4"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.4" cy="6.6" r="1.1" fill="currentColor" stroke="none"/>',
            'linkedin'  => '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-4 0v7h-4v-11h4v1.5A6 6 0 0 1 16 8z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/>',
            /* Outline glyph — the filled version needed a hardcoded #fff play triangle,
               which only looked right on a dark footer. */
            'youtube'   => '<rect x="2.2" y="5.2" width="19.6" height="13.6" rx="4.2"/><polygon points="10.2,9 15.4,12 10.2,15" fill="currentColor" stroke="none"/>',
            'whatsapp'  => '<path d="M20.5 3.5A10 10 0 0 0 3.6 15.1L2 22l7.1-1.6A10 10 0 1 0 20.5 3.5zm-8.4 16a8.3 8.3 0 0 1-4.2-1.2l-.3-.2-3.1.7.7-3-.2-.3a8.3 8.3 0 1 1 7.1 4zm4.6-6.2c-.3-.1-1.5-.7-1.7-.8s-.4-.1-.6.1l-.8 1c-.1.2-.3.2-.5.1a6.8 6.8 0 0 1-3.4-2.9c-.1-.3 0-.4.1-.5l.4-.5a1.6 1.6 0 0 0 .2-.4.4.4 0 0 0 0-.4l-.8-1.9c-.2-.5-.4-.4-.6-.4h-.5a1 1 0 0 0-.7.3 3 3 0 0 0-.9 2.2 5.2 5.2 0 0 0 1.1 2.7 11.9 11.9 0 0 0 4.6 4 5.3 5.3 0 0 0 3.2.7 2.7 2.7 0 0 0 1.8-1.3 2.2 2.2 0 0 0 .2-1.3z"/>',
            'tiktok'    => '<path d="M21 8.5a6.5 6.5 0 0 1-4.2-1.5v7.6a6.6 6.6 0 1 1-5.7-6.5v3.3a3.3 3.3 0 1 0 2.4 3.2V2h3.3A4.2 4.2 0 0 0 21 5.6z"/>',
            'telegram'  => '<path d="M22 3 2 10.5l5.5 2L20 6l-9.5 9v4.5l3-3.5 4.5 3.5z"/>',
        ];
    }
    return $icons[$net] ?? '';
}

/* Most brand marks are solid shapes, but a couple only read correctly as outlines
 * (a filled Instagram rounded-rect is just a solid square). Those are drawn with
 * stroke instead of fill; the glyph markup opts individual sub-shapes back into
 * fill where it needs a solid dot or triangle. */
function hc_social_stroke(string $net): bool {
    return in_array($net, ['instagram', 'youtube'], true);
}

/* Renders the payment badge row. Images, so nothing here fabricates a card mark. */
function hc_payment_row(array $items, string $note = '', string $extraClass = ''): string {
    if (!$items) return '';
    /* Grouping is a property of the strip, not of a call site, so it is read here
     * rather than threaded through both callers. */
    if (!empty($GLOBALS['footerPaymentCard'])) {
        $extraClass = trim($extraClass . ' hc-foot-pay-card'
            . (!empty($GLOBALS['footerPaymentCardHover']) ? ' hc-foot-pay-card-hov' : ''));
    }
    $out = '<div class="hc-foot-pay' . ($extraClass !== '' ? ' ' . $extraClass : '') . '">';
    foreach ($items as $__p) {
        $__alt = (string)($__p['label'] ?? '');
        $__ttl = $__alt !== '' ? ' title="' . hc_esc($__alt) . '"' : '';
        if (!empty($__p['icon'])) {
            $out .= '<span class="hc-foot-pay-item hc-foot-pay-glyph"' . $__ttl . '>'
                  . '<i class="' . hc_esc((string)$__p['icon']) . '" aria-hidden="true"></i>'
                  . ($__alt !== '' ? '<span class="hc-sr-only">' . hc_esc($__alt) . '</span>' : '')
                  . '</span>';
            continue;
        }
        $out .= '<span class="hc-foot-pay-item">'
              . '<img src="' . hc_esc((string)$__p['image']) . '" alt="' . hc_esc($__alt) . '"'
              . $__ttl . ' loading="lazy" decoding="async"></span>';
    }
    $out .= '</div>';
    if ($note !== '') $out .= '<p class="hc-foot-pay-note">' . hc_esc($note) . '</p>';
    return $out;
}

/* Renders the saved social list. $extraClass lets the two placements style apart. */
function hc_social_row(array $links, string $extraClass = ''): string {
    if (!$links) return '';
    $out = '<div class="hc-foot-social' . ($extraClass !== '' ? ' ' . $extraClass : '') . '">';
    foreach ($links as $__s) {
        $__g = hc_social_icon((string)$__s['network']);
        if ($__g === '') continue;
        /* Solid marks fill; outline marks stroke. Either way the glyph rides on
         * currentColor, so one colour drives the whole row. */
        $__isStroke = hc_social_stroke((string)$__s['network']);
        $__paint = $__isStroke
            ? 'fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"'
            : 'fill="currentColor" stroke="none"';
        $out .= '<a class="hc-foot-social-link" href="' . hc_esc((string)$__s['url']) . '"'
              . ' target="_blank" rel="noopener noreferrer nofollow"'
              . ' aria-label="' . hc_esc(ucfirst((string)$__s['network'])) . '" title="' . hc_esc(ucfirst((string)$__s['network'])) . '">'
              . '<svg viewBox="0 0 24 24" width="17" height="17" ' . $__paint . ' aria-hidden="true" focusable="false">'
              . $__g . '</svg></a>';
    }
    return $out . '</div>';
}

/* PHASE_HC_ICON_ANYFORMAT — a category "icon" may be far more than an emoji:
 *   • an emoji / short text        → rendered as-is
 *   • an image URL or data: URI    → <img> (png/jpg/svg/webp/… — "any format")
 *   • raw inline <svg>/<i> markup  → sanitised + emitted verbatim
 *   • a font-icon class (Font Awesome / Bootstrap Icons / Material / Phosphor /
 *     Remix / MDI / Boxicons / Tabler)  → <i class="…"> (the matching webfont is
 *     auto-loaded in <head> when any category uses one — see hc_icon_font_links).
 * Returns safe HTML. Empty in → '' (caller supplies its own 📁 fallback). */
function hc_cat_icon_html($icon): string {
    $icon = trim((string)$icon);
    if ($icon === '') return '';
    // Image URL / protocol-relative / root-relative / data URI → <img>.
    if (preg_match('~^(https?://|//|/(?!/)|data:image/)~i', $icon)) {
        return '<img class="hc-ico-img" src="' . hc_esc(hc_asset_url($icon)) . '" alt="" loading="lazy" decoding="async">';
    }
    // Pasted markup (inline SVG or an <i>/<span> icon element) → sanitise + emit.
    if (strpos($icon, '<') !== false) {
        return hc_sanitize_icon_markup($icon);
    }
    // A font-icon class token (or two) → <i class="…">.
    if (hc_is_icon_font_class($icon)) {
        return '<i class="' . hc_esc($icon) . '" aria-hidden="true"></i>';
    }
    /* PHASE10K31_2026-08-13 — A BARE NAME IS AN ICON NAME.
     * Owner: "announcement icon is not rendering, showing megaphone."
     * Organize-with-AI and hand entry both write plain names like `megaphone`,
     * which matched none of the formats above and fell through to the branch
     * below — so the WORD was printed where the icon should be. Phosphor is
     * already one of the supported families and its name set covers these, so a
     * bare name resolves to its class and the font link loads with it.
     *
     * Deliberately narrow: lowercase ASCII letters and hyphens, three or more
     * characters. An emoji, a single letter, or a short caption like "NEW" is a
     * label somebody meant to show and is left exactly as it was. */
    if (preg_match('~^[a-z][a-z-]{2,}$~', $icon)) {
        return '<i class="ph ph-' . hc_esc($icon) . '" aria-hidden="true"></i>';
    }
    // Otherwise it's an emoji / short label.
    return hc_esc($icon);
}

/* True when $icon looks like a webfont icon class (not an emoji/URL/markup). */
function hc_is_icon_font_class($icon): bool {
    $icon = trim((string)$icon);
    if ($icon === '' || strpos($icon, '<') !== false) return false;
    if (!preg_match('~^[a-z0-9 :_.\-]+$~i', $icon)) return false;   // classes only
    return (bool)preg_match('~(^| )(fa[srlbdtk]?[ -]|fa-|bi-|material-icons|material-symbols|ph[- ]|ph-|ri-|glyphicon|mdi-|bx[sl]?-|ti-)~i', $icon);
}

/* Sanitise pasted icon markup: drop dangerous elements, on* handlers and any
 * javascript: URLs, then allow only a safe SVG/icon tag subset. */
function hc_sanitize_icon_markup(string $html): string {
    $html = preg_replace('~<\s*(script|style|iframe|object|embed|link|meta|foreignObject|image)\b~i', '<removed', $html);
    $html = preg_replace('~\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)~i', '', $html);
    $html = preg_replace('~(href|xlink:href|src)\s*=\s*("\s*javascript:[^"]*"|\'\s*javascript:[^\']*\')~i', '', $html);
    $allowed = '<svg><g><path><circle><rect><ellipse><line><polyline><polygon><defs>'
             . '<linearGradient><radialGradient><stop><clipPath><mask><use><symbol><title><desc><i><span>';
    return strip_tags($html, $allowed);
}

/* Build the <head> webfont <link>s needed by any category's font-icon class.
 * Only libraries actually referenced are loaded, and only once. */
function hc_icon_font_links(array $cats): string {
    $need = [];
    foreach ($cats as $c) {
        $ic = trim((string)($c['icon'] ?? ''));
        /* A bare name renders as a Phosphor class (see hc_cat_icon_html), so it
         * needs the Phosphor stylesheet exactly as an explicit `ph-` class does. */
        if (preg_match('~^[a-z][a-z-]{2,}$~', $ic) && !hc_is_icon_font_class($ic)) { $need['ph'] = 1; continue; }
        if ($ic === '' || !hc_is_icon_font_class($ic)) continue;
        if (preg_match('~(^| )(fa[srlbdtk]?[ -]|fa-)~i', $ic))            $need['fa']  = 1;
        if (preg_match('~(^| )bi-~i', $ic))                               $need['bi']  = 1;
        if (preg_match('~material-icons|material-symbols~i', $ic))        $need['mi']  = 1;
        if (preg_match('~(^| )(ph[- ]|ph-)~i', $ic))                      $need['ph']  = 1;
        if (preg_match('~(^| )ri-~i', $ic))                               $need['ri']  = 1;
        if (preg_match('~(^| )mdi-~i', $ic))                              $need['mdi'] = 1;
        if (preg_match('~(^| )bx[sl]?-~i', $ic))                          $need['bx']  = 1;
        if (preg_match('~(^| )ti-~i', $ic))                               $need['ti']  = 1;
    }
    if (!$need) return '';
    $map = [
        'fa'  => 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css',
        'bi'  => 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
        'mi'  => 'https://fonts.googleapis.com/icon?family=Material+Icons',
        'ph'  => 'https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css',
        'ri'  => 'https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css',
        'mdi' => 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css',
        'bx'  => 'https://cdn.jsdelivr.net/npm/boxicons@2.1.4/css/boxicons.min.css',
        'ti'  => 'https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.3.0/dist/tabler-icons.min.css',
    ];
    $out = '';
    foreach (array_keys($need) as $k) {
        if (!isset($map[$k])) continue;
        /* AUDIT 2026-09-07 (lane 23, #510/#512) — pinned versions carry their subresource-integrity hash. */
        $sri = [
            $map['fa'] => 'sha384-PPIZEGYM1v8zp5Py7UjFb79S58UeqCL9pYVnVPURKEqvioPROaVAJKKLzvH2rDnI',
            $map['bi'] => 'sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+',
            $map['ph'] => 'sha384-6p9AefaqUhEVheRlj1mpAkbngHXy9mbYMrIdcIt4Jlc9lOLIablJq3bBsLOjGwZ7',
            $map['ri'] => 'sha384-6FSSi597BTd6QcnsBNoLclRKxTOyyYqkaucRjFgCNr8wHVCp0COLClSPY4Vy/bjh',
            $map['mdi'] => 'sha384-HphS8cQyN+eYiJ5PMbzShG6qZdRtvHPVLPkYb8JwMkmNgaIxrFVDhQe3jIbq3EZ2',
            $map['bx'] => 'sha384-42kyIPf7HDYLkGffmxDhSx/3Z/53wGBs3nD6wEFxsbeDc7rMO6mkYbkAcpRsnMU2',
            $map['ti'] => 'sha384-ir0fR0HzKEQ1x1XwqY5+0wIbrIgY45nvWPjIQy7+rjcwW7L0cHDytvEHKS3tIxBZ',
        ];
        $out .= '<link rel="stylesheet" href="' . hc_esc($map[$k]) . '"' . (isset($sri[$map[$k]]) ? ' integrity="' . $sri[$map[$k]] . '"' : '') . ' crossorigin="anonymous">' . "\n";
    }
    return $out;
}

/* PHASE_HC_ICON_ANYFORMAT — article-card excerpts imported/scraped from a live
 * site are often raw page dumps: they lead with a boilerplate TLD list
 * (".ng · .com · .co.za · …"), repeat the page <title> inline, and carry nav
 * crumbs ("« Back", "Close"). Tidy them at render time so cards read cleanly.
 * Conservative + general: only removes patterns that are unambiguously chrome. */
function hc_clean_excerpt($title, $excerpt, int $maxChars = 220): string {
    $s = trim((string)$excerpt);
    if ($s === '') return '';
    $title = trim((string)$title);

    // 1. Collapse all whitespace to single spaces.
    $s = preg_replace('~\s+~u', ' ', $s);
    // 2. Drop runs of middot/bullet/pipe-separated dotted tokens (TLD/nav lists).
    $s = preg_replace('~\.?[a-z0-9][a-z0-9.\-]{1,18}(?:\s*[·•|]\s*\.?[a-z0-9][a-z0-9.\-]{1,18}){2,}~iu', ' ', $s);
    // 3. Remove obvious nav crumbs.
    $s = preg_replace('~[«»]\s*Back\b|\bClose\b~iu', ' ', $s);
    // 4. Remove the page title wherever it was inlined into the body text.
    if ($title !== '' && mb_strlen($title, 'UTF-8') >= 4) {
        $s = str_ireplace($title, ' ', $s);
    }
    // 5. Re-collapse + strip leading separators/punctuation left behind.
    $s = preg_replace('~\s+~u', ' ', $s);
    $s = preg_replace('~^[\s.\x{00B7}\x{2022}|:\x{2013}\x{2014}\-]+~u', '', (string)$s);
    $s = trim((string)$s);
    if ($s === '') return '';
    // 6. Truncate to a card-friendly length on a word boundary.
    if (function_exists('mb_strlen') && mb_strlen($s, 'UTF-8') > $maxChars) {
        $cut = mb_substr($s, 0, $maxChars, 'UTF-8');
        $sp  = mb_strrpos($cut, ' ', 0, 'UTF-8');
        if ($sp !== false && $sp > $maxChars * 0.6) $cut = mb_substr($cut, 0, $sp, 'UTF-8');
        $s = rtrim($cut, " .,;:·•|-") . '…';
    }
    return $s;
}

/* PHASE_HC_DESIGN_STUDIO — turn a stored colour JSON into a CSS value.
 * {"m":"solid","c":"#hex"} → "#hex"; {"m":"grad","s":["#a","#b"],"a":135} →
 * "linear-gradient(135deg,#a,#b)". Returns '' when unset/invalid so callers
 * fall back to the theme default. Only hex colours + an integer angle are ever
 * emitted, so the value is safe to inline into CSS. */
function hc_ds_color($raw, string $theme = 'light'): string {
    /* PHASE_HC_WIDGET_STUDIO — one implementation, in the model, so help_widget.php
     * resolves launcher/header colours through the same code. The inline copy below
     * stays as the fallback for a release where the class is unavailable. */
    if (class_exists('\\OpsIQ\\Kb\\HelpCenter')) return HelpCenter::dsColor($raw, $theme);
    /* PHASE_HC_PALETTE_PER_THEME_2026-08-26 — the fallback copy understands the LIGHT
     * value only. Asking it for dark returns '' — "no dark value declared" — which
     * hc_ds_rule() reads as "leave dark to the theme". A release without the class
     * therefore degrades to exactly the single-value behaviour it had before, rather
     * than emitting a half-resolved pair. */
    if ($theme === 'dark') return '';
    $raw = trim((string)$raw);
    if ($raw === '' || $raw === '{}') return '';
    $d = json_decode($raw, true);
    if (!is_array($d)) return '';
    $okHex = static function ($c): string { $c = (string)$c; return preg_match('/^#[0-9a-fA-F]{3,8}$/', $c) ? $c : ''; };
    if (($d['m'] ?? 'solid') === 'grad') {
        $stops = array_values(array_filter(array_map($okHex, (array)($d['s'] ?? []))));
        if (count($stops) >= 2) {
            $angle = max(0, min(360, (int)($d['a'] ?? 135)));
            return 'linear-gradient(' . $angle . 'deg,' . implode(',', $stops) . ')';
        }
        return $okHex($d['c'] ?? ($stops[0] ?? ''));   // graceful fallback to a solid
    }
    return $okHex($d['c'] ?? '');
}
/* Build a CSS rule that applies a design-studio colour to $selectors. When
 * $isText, a gradient is painted onto the text via background-clip. */
function hc_ds_decl(string $val, bool $isText, string $inset = ''): string {
    $isGrad = (strncmp($val, 'linear-gradient', 15) === 0);
    if ($isText) {
        return $isGrad
            ? '{background:' . $val . ';-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent!important}'
            : '{color:' . $val . '!important}';
    }
    /* PHASE_HC_DS_INSET_2026-08-27 — A FILL SHIPS WITH THE GEOMETRY IT NEEDS.
     *
     * Owner: *"the text sits flush against the fill's edge with no inset."* A
     * surface role emits `background` and nothing else, so whether the colour an
     * operator picks reads as a designed band or as a highlighter smeared over the
     * words depends entirely on whether the element it lands on happened to be
     * given padding by its presentation. Measured across all 27 category
     * presentations, painting `.hc-dir-head` put the fill hard against the glyphs
     * on EIGHT of them (campus, cloud, cmddir, compact, editorial, index, journey,
     * marquee — top gaps of -2px to 0px) and `.hc-dir-count` on three more
     * (compact, marquee, toc). Those elements are not missing padding by mistake:
     * with no fill there is nothing for an inset to do, so none was ever needed.
     *
     * The inset therefore belongs WITH the fill, not in the presentation. Emitted
     * only on the roles that declare one, and only when the operator has actually
     * picked a colour — a workspace that has painted nothing renders exactly the
     * CSS it rendered before this existed.
     *
     * The column is a raw CSS fragment rather than a bare padding value, and each
     * declaration in it carries its own !important, written out. Two reasons. The
     * zeroing rules are not all reachable without it —
     * `#hc-page#hc-page .hc-dir-v-campus .hc-dir-head{padding:0}` is (2,2,0) — and the
     * count needed more than padding: on `tree` it renders as a 2.9em numeral with
     * `line-height:1`, so a padded box still had the glyph hanging 7px out the top of
     * its own fill. Spelling the declarations out keeps that visible at the call site
     * instead of hidden in the emitter.
     *
     * A presentation that already had a good inset is moved to the house value when
     * painted — measured worst case 5px, on console — which is the price of one rule
     * instead of twenty-seven. */
    return '{background:' . $val . '!important'
         . ($inset !== '' ? ';' . rtrim($inset, ';') : '')
         . '}';
}
/**
 * PHASE_HC_PALETTE_PER_THEME_2026-08-26 — scope a selector LIST to one theme.
 *
 * Per comma, never once for the whole list: `html[data-theme="dark"] a,b` scopes `a`
 * and leaves `b` global, which would have painted the light value onto dark mode for
 * every multi-selector role — and thirteen of the forty are multi-selector.
 *
 * data-theme is always present. The boot script in <head> stamps it before first paint,
 * resolving `auto` from the OS, and there is deliberately no prefers-color-scheme block
 * anywhere in this file (see the note beside the dark palette) — so these two scopes are
 * complete and mutually exclusive rather than leaving an unstyled third state.
 */
function hc_ds_scope(string $selectors, string $theme, bool $strong = false): string {
    /* PHASE_HC_PALETTE_PER_THEME_2026-08-26 — $strong DOUBLES the theme condition.
     *
     * `html[data-theme="dark"] #hc-page{background:…!important}` is (1,1,1), and
     * hc-sheet-10.css:—  carries `html[data-theme="dark"] #hc-page{background:var(--hc-d-bg)
     * !important}` at exactly the same specificity. Equal specificity and both !important
     * means DOCUMENT ORDER decides, and that sheet is linked after this inline block — so
     * every dark SURFACE a preset set was silently discarded while light mode worked
     * perfectly. Measured on a live page: the dark page background computed to `none`.
     *
     * Repeating the attribute is valid CSS and adds one selector's worth of specificity
     * ((0,2,1) instead of (0,1,1)) without touching a single stylesheet — the same reason
     * HcFx and the sidebar rule double `#hc-page`. It is applied ONLY on the per-theme
     * path: a value with no dark branch still emits exactly the selector it always did. */
    $prefix = $theme === 'dark'
        ? ($strong ? 'html[data-theme="dark"][data-theme="dark"] ' : 'html[data-theme="dark"] ')
        : ($strong ? 'html:not([data-theme="dark"]):not([data-theme="dark"]) ' : 'html:not([data-theme="dark"]) ');
    $out = [];
    foreach (explode(',', $selectors) as $sel) {
        $sel = trim($sel);
        if ($sel === '') continue;
        /* PHASE_HC_PALETTE_PER_THEME_2026-08-26 — on the per-theme path, DOUBLE the first id.
         *
         * Repeating the theme attribute is not enough on its own: specificity compares ID
         * COUNT first, so no number of attributes can beat a two-id rule. hc-sheet-10.css
         * carries `html[data-theme="dark"] #hc-page #hc-hero{…!important}` — (2,1,1) — and
         * the hero kept the sheet's fill while every other surface took the preset's.
         * Doubling gives `#hc-hero#hc-hero` and (2,2,1), which wins. Same idiom HcFx and the
         * sidebar rule already use, and it selects exactly the same elements. */
        if ($strong) $sel = preg_replace('~#([A-Za-z][\w-]*)~', '#$1#$1', $sel, 1);
        $out[] = $prefix . $sel;
    }
    return implode(',', $out);
}
/**
 * PHASE_HC_DS_REACH_2026-08-27 — REPEAT THE FIRST ID N TIMES.
 *
 * `hc_ds_scope()` already does this once, on the per-theme path, for the reason set out
 * there: specificity compares ID COUNT first, so no number of classes can beat an extra
 * id. This is the same idiom exposed as a per-ROLE strength, because the directory roles
 * need it on BOTH paths and need it twice.
 *
 * WHY. The $__ds table has claimed since PHASE0.5_2026-08-09 that "every rule below emits
 * !important, so a layout variant's own header/pill/chip colour is overridden by an
 * explicit pick without needing to out-specify nine separate .hc-dir-v-* rules." That was
 * true when every variant rule was `#hc-page .hc-dir-v-x .hc-dir-head` — (1,2,0) against
 * an important (1,1,0), and !important settled it. Later phases rewrote the newer
 * variants at `#hc-page#hc-page .hc-dir-v-x .hc-dir-head` — (2,2,0) — and !important does
 * NOT settle a contest between two important declarations: specificity does, and (1,1,0)
 * loses. The claim quietly stopped being true and nothing said so.
 *
 * MEASURED, in a browser, painting each role on each of the 27 cat_layout presentations:
 * eleven role/presentation pairs saved a colour and changed nothing on screen —
 * `ds_dir_card` on archdir, campus, cmddir, krail and matrix; `ds_dir_head` and
 * `ds_dir_count` on krail, matrix and tree. Owner: *"make it work properly."*
 *
 * Three ids — (3,1,0) — clears (2,2,0), which is the strongest reset any variant carries.
 * It selects exactly the same elements; it only says who wins.
 */
function hc_ds_boost(string $selectors, int $times): string {
    if ($times < 1) return $selectors;
    $out = [];
    foreach (explode(',', $selectors) as $sel) {
        $sel = trim($sel);
        if ($sel === '') continue;
        /* The FIRST id only, and only if there is one — a selector with no id is left
         * alone rather than being given a fabricated one. */
        $out[] = preg_replace_callback('~#([A-Za-z][\w-]*)~', static function (array $m) use ($times): string {
            return str_repeat($m[0], $times + 1);
        }, $sel, 1);
    }
    return implode(',', $out);
}
/**
 * PHASE_HC_PALETTE_PER_THEME_2026-08-26 — one role, up to two rules.
 *
 * $lightOnly marks a role whose SINGLE value must not reach dark mode. It was carried
 * as a hand-written `html:not([data-theme="dark"])` prefix inside five selector strings
 * in the table below; it is a property of the role, not of the selector, and writing it
 * into the string made it impossible to add the dark half without producing
 * `html:not(...) html:not(...)`. The five now declare it here and the scoping is done
 * in one place.
 *
 * THE THREE OUTCOMES, and why the first two are byte-identical to what shipped before:
 *   no dark value, not lightOnly  → one UNSCOPED rule            (unchanged)
 *   no dark value, lightOnly      → one LIGHT-scoped rule        (unchanged)
 *   a dark value                  → a light-scoped AND a dark-scoped rule
 *
 * So a workspace that has never stored a dark branch — which is every workspace until a
 * preset or the Studio writes one — renders the same CSS it rendered yesterday.
 */
function hc_ds_rule(string $raw, string $selectors, bool $isText = false, bool $lightOnly = false, string $inset = '', int $boost = 0): string {
    $light = hc_ds_color($raw);
    $dark  = hc_ds_color($raw, 'dark');
    if ($light === '' && $dark === '') return '';

    /* Applied BEFORE scoping, so the per-theme path's own doubling stacks on top of it
     * rather than replacing it — a boosted role in dark mode needs both. */
    $selectors = hc_ds_boost($selectors, $boost);

    if ($dark === '') {
        $sel = $lightOnly ? hc_ds_scope($selectors, 'light') : $selectors;
        return $sel . hc_ds_decl($light, $isText, $inset);
    }

    $css = '';
    /* A dark-only value is legal: it leaves light mode to the theme, which is the
     * mirror image of the light-only role above. */
    if ($light !== '') $css .= hc_ds_scope($selectors, 'light', true) . hc_ds_decl($light, $isText, $inset);
    $css .= hc_ds_scope($selectors, 'dark', true) . hc_ds_decl($dark, $isText, $inset);
    return $css;
}

/**
 * PHASE_HC_DS_REACH_2026-08-27 — WHICH DIRECTORY SURFACES THE OPERATOR HAS PAINTED.
 *
 * THE PRESENTATION HAS TO MAKE ROOM FOR A FILL. Three of the directory surfaces cannot
 * be fixed by winning the cascade, because the element the role names has no box to
 * paint: `archdir` and `spine` flatten `.hc-dir-head` with `display:contents` so its
 * children can be placed directly in the card's grid, and `cloud` sets `display:none` on
 * `.hc-dir-count` because a chip cloud has no room for one. A fill on any of the three
 * computes perfectly and renders a 0x0 box.
 *
 * Six more presentations give `.hc-dir-card` no inset at all — measured t-1 and l0 on
 * campus, cloud, cmddir, compact, editorial and marquee — and the card cannot be given a
 * blanket one from the emit table the way the header was: `ribbon` and `index` bleed a
 * header band to the card's edges, and padding on the card would float that band in the
 * middle of it, which is the very defect this work started from.
 *
 * Both need the PRESENTATION to change shape, and only when the operator has actually
 * painted the thing. So the fact that they painted it is published as a class on
 * #hc-page and hc-sheet-6 carries the per-presentation geometry behind it. No paint, no
 * class, no change — a workspace that has painted none renders the markup it always did.
 *
 * Computed here rather than beside the <style> block that emits the colours, because
 * hc_build_view() runs BEFORE that block and would have read an empty global. It uses
 * the same emptiness test hc_ds_rule applies, so the class and the rule can never
 * disagree: a stored '{}' is "no colour" and stamps nothing.
 */
/**
 * The four sidebar overrides as ONE string, or '' when every one of them is on match.
 *
 * A function rather than an inline block so the guard test can lift and RUN it, the way
 * HcSurfaceRoleInsetTest lifts the ds emitters — a test that re-types the mapping proves
 * only that the test agrees with itself.
 */
function hc_sidebar_card_override_css(array $settings): string {
    /* value => the declarations it sets. The right-hand sides are var() references into
     * the --sc-* scale hc-sheet-1 declares ONCE; no number appears here, on purpose. */
    static $map = [
        'sidebar_card_radius'  => [
            'sharp'    => '--card-radius:var(--sc-radius-sharp)',
            'soft'     => '--card-radius:var(--sc-radius-soft)',
            'sculpted' => '--card-radius:var(--sc-radius-sculpted)',
        ],
        'sidebar_card_depth'   => [
            'flat'     => '--card-shadow:var(--sc-shadow-flat);--card-shadow-hover:var(--sc-shadow-flat-hover)',
            'deep'     => '--card-shadow:var(--sc-shadow-deep);--card-shadow-hover:var(--sc-shadow-deep-hover)',
            'extreme'  => '--card-shadow:var(--sc-shadow-extreme);--card-shadow-hover:var(--sc-shadow-extreme-hover)',
        ],
        'sidebar_card_density' => [
            'compact'     => '--card-pad-x:var(--sc-pad-compact-x);--card-pad-y:var(--sc-pad-compact-y);--card-gap:var(--sc-gap-compact)',
            'comfortable' => '--card-pad-x:var(--sc-pad-comfortable-x);--card-pad-y:var(--sc-pad-comfortable-y);--card-gap:var(--sc-gap-comfortable)',
            'airy'        => '--card-pad-x:var(--sc-pad-airy-x);--card-pad-y:var(--sc-pad-airy-y);--card-gap:var(--sc-gap-airy)',
        ],
        'sidebar_card_style'   => [
            'lifted'   => '--card-border-w:var(--sc-edge-w-lifted);--card-edge:var(--sc-edge-lifted)',
            'bordered' => '--card-border-w:var(--sc-edge-w-bordered);--card-edge:var(--sc-edge-bordered)',
            'soft'     => '--card-border-w:var(--sc-edge-w-soft);--card-edge:var(--sc-edge-soft)',
        ],
    ];

    $decl = '';
    foreach ($map as $key => $opts) {
        $v = strtolower(trim((string)($settings[$key] ?? 'match')));
        /* 'match', blank, and anything unrecognised all mean the same: emit NOTHING.
         * An unknown value must never fall through to a default that paints something —
         * a stored value from a future release would then quietly restyle the column. */
        if ($v === '' || $v === 'match' || !isset($opts[$v])) continue;
        $decl .= $opts[$v] . ';';
    }
    if ($decl === '') return '';

    /* Both places the rail lives: inside .hc-sidebar on the article and category views,
     * and a direct child of .hc-page-shell on the browse views. */
    return '#hc-page .hc-sidebar,#hc-page .hc-page-shell>.hc-kb-sidebar{' . $decl . '}';
}

function hc_ds_dir_flags(): string {
    static $out = null;
    if ($out !== null) return $out;
    $st  = (isset($GLOBALS['_settings']) && is_array($GLOBALS['_settings'])) ? $GLOBALS['_settings'] : [];
    $out = '';
    foreach (['ds_dir_card' => 'hc-ds-dircard', 'ds_dir_head' => 'hc-ds-dirhead', 'ds_dir_count' => 'hc-ds-dircount'] as $k => $cls) {
        $raw = (string)($st[$k] ?? '');
        if (hc_ds_color($raw) !== '' || hc_ds_color($raw, 'dark') !== '') $out .= ' ' . $cls;
    }
    return $out;
}

$_hcPersist = [];
if (trim((string)($_GET['site_key'] ?? $_GET['site'] ?? '')) !== '') $_hcPersist['site_key'] = $_siteKey;
if ($_embed) $_hcPersist['embed'] = '1';
/* PHASE_HC_WIDGET — widget mode has to survive a click. $_hcPersist is what carries
 * state onto every link and search form in the page; without `widget` in it, only
 * the panel's FIRST page was the compact layout and the moment you opened a
 * category the link dropped back to the full embed page (rail stacked on top, no
 * back bar, TOC rail on articles). The loader can only set the flag once. */
if ($_widget) { $_hcPersist['widget'] = '1'; $_hcPersist['side'] = $_widgetSide; }
/* PHASE_HC_WIDGET_LOOK — the skin has to survive a click too, or the second page in the
 * panel would drop back to the classic look. */
if ($_widget && $_wdVariant !== '') { $_hcPersist['wd'] = $_wdVariant; if (!$_wdHead) $_hcPersist['wh'] = '0'; }
/* PHASE_HC_I18N — the language has to survive a click, exactly like `widget`.
 * $_hcPersist is what stamps state onto every link AND the search form; leave
 * `lang` out and the first click drops the visitor back to the source language.
 * Only stamped when it differs from the source, so default-language URLs stay
 * clean (and canonical/SEO stays on the bare URL). */
if ($_i18nOn && $_locale !== $_i18nSource) $_hcPersist['lang'] = $_locale;
/**
 * PHASE_HC_WIDGET_STUDIO — the panel's "Open in help center" target.
 *
 * The panel is a 420px iframe, so this is the reader's way out to the full page.
 * Two modes, mirroring the client chat widget's link but without the crawled-source
 * options (the widget always knows which help center it is showing):
 *   normal — this help center's own public URL for the article
 *   own    — the same article on the customer's proxied domain
 * Returns '' when there is nothing sensible to link to, and every caller must
 * treat that as "render no link" rather than linking to a broken page.
 */
function hc_widget_help_url(string $slug): string {
    global $widgetHelpLinkMode, $widgetHelpLinkUrl, $helpBase, $_hcPersist;
    if ($widgetHelpLinkMode === 'own') {
        $base = rtrim($widgetHelpLinkUrl, '/');
        if ($base === '' || !preg_match('~^https?://~i', $base)) return '';   // not set up = no link
        return $slug !== '' ? $base . '?article=' . rawurlencode($slug) : $base;
    }
    /* 'normal' — the public page. Deliberately drops embed/widget from $_hcPersist:
     * the whole point of this link is to LEAVE the panel, so it must not hand back
     * the compact in-panel layout. */
    $qp = $_hcPersist;
    unset($qp['embed'], $qp['widget']);
    if ($slug !== '') $qp = array_merge(['article' => $slug], $qp);
    return $helpBase . ($qp ? '?' . http_build_query($qp) : '');
}
/**
 * PHASE_HC_I18N — the CURRENT page in another language.
 *
 * The picker must keep you where you are: switching to French on an article has to
 * land on that same article in French, not throw you back to the home page. So this
 * rebuilds the current query with `lang` swapped, rather than starting from scratch.
 * $lang === null strips it (the source language rides a clean URL).
 */
function hc_u_lang($lang): string {
    global $helpBase, $_hcPersist;
    $qp = $_GET;
    /* PATH_INFO/route junk and the cache-buster never belong in a link. */
    unset($qp['_hcajax'], $qp['cb']);
    $qp = array_merge($qp, $_hcPersist);
    if ($lang === null) unset($qp['lang']);
    else                $qp['lang'] = $lang;
    return $helpBase . ($qp ? '?' . http_build_query($qp) : '');
}
function hc_u(string $query = ''): string {
    global $helpBase, $_hcPersist;
    parse_str($query, $qp);
    $all = array_merge($qp, $_hcPersist);
    return $helpBase . ($all ? '?' . http_build_query($all) : '');
}

/**
 * Browser-facing current /HC URL carried through the Portal sign-in entry.
 * Internal routing, preview, AJAX and credential parameters are never copied.
 */
function hc_login_return_url(): string {
    global $helpBase, $_hcPersist;
    $qp = is_array($_GET) ? $_GET : [];
    foreach ([
        '_hcajax','cb','hslug','route','site_key','identity_token','magic_verify',
        'pw_verify','pw_reset','sso_error','px_preview','px_demo','px_review',
        'opsiq_portal_return'
    ] as $key) unset($qp[$key]);
    $qp = array_merge($qp, is_array($_hcPersist) ? $_hcPersist : []);
    foreach ($qp as $key => $value) {
        if (!is_scalar($value) || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$key)) unset($qp[$key]);
    }
    return $helpBase . ($qp ? '?' . http_build_query($qp) : '');
}

/** One stable Portal handoff URL for built-in /HC chrome, custom chrome and gates. */
function hc_portal_login_url(): string {
    if (!function_exists('opsiq_portal_public_base')) {
        $__pe = ($GLOBALS['_opsiqRoot'] ?? dirname(__FILE__)) . '/opsiq/opsiq.portal_experience.php';
        if (is_file($__pe)) { try { require_once $__pe; } catch (\Throwable $e) {} }
    }
    $base = function_exists('opsiq_portal_public_base')
        ? (string)opsiq_portal_public_base((string)($GLOBALS['_siteKey'] ?? ''))
        : '';
    if ($base === '') return '';
    $sep = strpos($base, '?') === false ? '?' : '&';
    return $base . $sep . 'p=signin&next=' . rawurlencode(hc_login_return_url());
}
function hc_hidden(): string {
    global $_hcPersist;
    $h = '';
    foreach ($_hcPersist as $k => $v)
        $h .= '<input type="hidden" name="' . hc_esc((string)$k) . '" value="' . hc_esc((string)$v) . '">';
    return $h;
}

/* ── Minimal markdown renderer ───────────────────────────────────────────── */
function hc_md(string $raw): string {
    /* Protect fenced code blocks */
    $fences = [];
    $raw = preg_replace_callback('/```[^\n]*\n(.*?)```/s', function ($m) use (&$fences) {
        $ph = "\x02F" . count($fences) . "\x03";
        $fences[$ph] = '<pre><code>' . htmlspecialchars($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
        return "\n" . $ph . "\n";
    }, $raw) ?? $raw;
    /* Protect inline code */
    $codes = [];
    $raw = preg_replace_callback('/`([^`\n]+)`/', function ($m) use (&$codes) {
        $ph = "\x02C" . count($codes) . "\x03";
        $codes[$ph] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code>';
        return $ph;
    }, $raw) ?? $raw;
    /* Escape everything else */
    $raw = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    /* Block elements */
    $raw = preg_replace('/^#{4,} (.+)$/m', '<h4>$1</h4>', $raw) ?? $raw;
    $raw = preg_replace('/^### (.+)$/m',   '<h3>$1</h3>', $raw) ?? $raw;
    $raw = preg_replace('/^## (.+)$/m',    '<h2>$1</h2>', $raw) ?? $raw;
    $raw = preg_replace('/^# (.+)$/m',     '<h2>$1</h2>', $raw) ?? $raw;
    $raw = preg_replace('/^&gt; (.+)$/m', '<blockquote>$1</blockquote>', $raw) ?? $raw;
    $raw = preg_replace('/^(?:---+|\*\*\*+)$/m', '<hr>', $raw) ?? $raw;
    /* Lists */
    $lines = explode("\n", $raw); $out = []; $inUl = false; $inOl = false;
    foreach ($lines as $ln) {
        if (preg_match('/^[-*+] (.+)$/', $ln, $m)) {
            if ($inOl) { $out[] = '</ol>'; $inOl = false; }
            if (!$inUl) { $out[] = '<ul>'; $inUl = true; }
            $out[] = '<li>' . $m[1] . '</li>';
        } elseif (preg_match('/^\d+\. (.+)$/', $ln, $m)) {
            if ($inUl) { $out[] = '</ul>'; $inUl = false; }
            if (!$inOl) { $out[] = '<ol>'; $inOl = true; }
            $out[] = '<li>' . $m[1] . '</li>';
        } else {
            if ($inUl) { $out[] = '</ul>'; $inUl = false; }
            if ($inOl) { $out[] = '</ol>'; $inOl = false; }
            $out[] = $ln;
        }
    }
    if ($inUl) $out[] = '</ul>';
    if ($inOl) $out[] = '</ol>';
    $raw = implode("\n", $out);
    /* Inline styles */
    $raw = preg_replace('/\*\*\*(.+?)\*\*\*/', '<strong><em>$1</em></strong>', $raw) ?? $raw;
    $raw = preg_replace('/\*\*(.+?)\*\*/',     '<strong>$1</strong>', $raw) ?? $raw;
    $raw = preg_replace('/\*([^*\n]+)\*/',     '<em>$1</em>', $raw) ?? $raw;
    $raw = preg_replace('/__(.+?)__/',         '<strong>$1</strong>', $raw) ?? $raw;
    $raw = preg_replace('/_([^_\n]+)_/',       '<em>$1</em>', $raw) ?? $raw;
    /* PHASE0_2026-08-06 — markdown images. Must run BEFORE the link rule below:
     * without this, the link regex matches the "[alt](url)" remainder of
     * "![alt](url)" and leaves a stray "!" in the rendered page (the audit's
     * broken-image finding). Only http(s) and root-relative sources render;
     * anything else is shown escaped, never executed. */
    $raw = preg_replace_callback('/!\[([^\]]*)\]\(([^\)\s]+)\)/', function ($m) {
        $src = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
        if (!preg_match('#^(https?://|/(?!/))#i', $src)) return hc_esc($m[0]);
        return '<img src="' . hc_esc($src) . '" alt="' . hc_esc($m[1]) . '" loading="lazy" decoding="async">';
    }, $raw) ?? $raw;
    $raw = preg_replace_callback('/\[([^\]]+)\]\(([^\)]+)\)/', function ($m) {
        $href = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
        if (!preg_match('/^https?:\/\//', $href)) return hc_esc($m[0]);
        return '<a href="' . hc_esc($href) . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
    }, $raw) ?? $raw;
    /* Paragraphs */
    $blocks = preg_split('/\n{2,}/', $raw) ?: [];
    $html = '';
    foreach ($blocks as $blk) {
        $blk = trim($blk);
        if ($blk === '') continue;
        $startsBlock = preg_match('/^<(h[2-4]|ul|ol|blockquote|pre|hr|\x02)/', $blk)
                    || preg_match('/^\x02[FC]/', $blk);
        $html .= $startsBlock ? $blk . "\n" : '<p>' . nl2br($blk) . "</p>\n";
    }
    /* Restore protected tokens */
    foreach ($fences as $ph => $v) { $html = str_replace(hc_esc($ph), $v, $html); $html = str_replace($ph, $v, $html); }
    foreach ($codes  as $ph => $v) { $html = str_replace(hc_esc($ph), $v, $html); $html = str_replace($ph, $v, $html); }
    return $html;
}



/**
 * STRUCTURE HARDENING (2026-07-14) — an article body is UNTRUSTED markup. The AI
 * "Rewrite & format" pass can be cut off mid-element (a real case: output
 * truncated inside a pricing table, leaving <table><tbody><tr><td> unclosed), a
 * crawl or import can carry a half-written tag, and a customer can paste
 * anything. strip_tags() whitelists tags but NEVER balances them, so those
 * dangling open tags used to swallow everything after them — sidebar, footer,
 * the whole shell — and collapse the Help Center layout to one side.
 *
 * Re-parse the fragment with libxml (which auto-closes every open element per
 * the HTML5 parsing rules) and re-serialize it. Whatever goes in, what comes out
 * is a WELL-FORMED fragment that physically cannot escape its container. This is
 * the Help Center's guarantee: no article content can ever break the page.
 */
function hc_balance_html(string $html): string {
    $html = trim($html);
    if ($html === '' || !class_exists('DOMDocument')) return $html;
    $doc  = new DOMDocument();
    $prev = libxml_use_internal_errors(true);
    // Wrap in a marker element so the repaired children can be lifted back out.
    $ok = $doc->loadHTML(
        '<?xml encoding="utf-8"?><div data-hc-root="1">' . $html . '</div>',
        LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
    );
    libxml_clear_errors();
    libxml_use_internal_errors($prev);
    if (!$ok) return $html;                       // parser gave up — return as-is
    $xp   = new DOMXPath($doc);
    $root = $xp->query('//div[@data-hc-root="1"]')->item(0);
    if (!$root) return $html;
    $out = '';
    foreach ($root->childNodes as $child) $out .= $doc->saveHTML($child);
    $out = trim($out);
    return $out !== '' ? $out : $html;            // never blank out a non-empty body
}

/** Legacy crawls commonly carry meaningful screenshots with alt="". Preserve every
 * authored alternative, but give an empty one a stable, language-neutral fallback
 * based on the translated article title plus its sequence number. This runs only
 * at render time, so old source content and stored translations remain untouched. */
function hc_article_image_alts(string $html, string $articleTitle): string {
    $articleTitle = trim($articleTitle);
    if ($html === "" || $articleTitle === "") return $html;
    $n = 0;
    return preg_replace_callback("/<img\b[^>]*>/i", function (array $m) use ($articleTitle, &$n): string {
        $n++;
        $tag = $m[0];
        if (!preg_match("/\balt\s*=\s*\"([^\"]*)\"/i", $tag, $am)) return $tag;
        $existing = html_entity_decode((string)($am[1] ?? ""), ENT_QUOTES, "UTF-8");
        if (trim($existing) !== "") return $tag;
        $fallback = hc_esc($articleTitle . " — " . $n);
        return preg_replace("/\balt\s*=\s*\"[^\"]*\"/i", "alt=\"" . $fallback . "\"", $tag, 1) ?? $tag;
    }, $html) ?? $html;
}

function hc_article_body(string $raw, string $articleTitle = ""): string {
    $trim = trim($raw);
    if ($trim !== '' && preg_match('/<\s*(article|section|div|h2|h3|p|ul|ol|table|blockquote)\b/i', $trim)) {
        $html = preg_replace('/<\/?(?:script|style|iframe|object|embed|form|input|button|textarea|select|option|html|head|body|meta|link)[^>]*>/i', '', $trim) ?? $trim;
        /* PHASE0_2026-08-06 — <img>, <figure>, <figcaption> are now allowed. The
         * audit found every body image silently stripped (the .hc-body img CSS at
         * the bottom of this file was unreachable) while image-led content is a
         * core help-center need. Safety is handled below by REBUILDING each <img>
         * rather than filtering it. */
        $allowed = '<article><section><div><h2><h3><h4><p><ul><ol><li><strong><b><em><i><a><table><thead><tbody><tr><th><td><blockquote><hr><code><pre><span><br><img><figure><figcaption>';
        $html = strip_tags($html, $allowed);
        /* AUDIT 2026-09-07 (lane 04, #1) — the two regexes below were the ONLY
         * attribute filter on public article bodies and five bypasses were proven
         * by execution (href="x"onclick=, /onmouseover=, unquoted javascript:,
         * newline-split scheme, entity-encoded scheme). The DOM-based
         * \OpsIQ\Security\HtmlSanitizer (10 bypass payloads probed, all blocked)
         * now runs first; the regexes stay as a belt for installs without it. */
        if (class_exists('\\OpsIQ\\Security\\HtmlSanitizer')) {
            try { $html = \OpsIQ\Security\HtmlSanitizer::clean($html); } catch (\Throwable $e) {}
        }
        $html = preg_replace('/\s(on\w+)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html) ?? $html;
        $html = preg_replace('/(href|src)\s*=\s*("|\')\s*javascript:[^"\']*("|\')/i', '$1="#"', $html) ?? $html;
        /* Every surviving <img> is rebuilt from scratch: only a validated src
         * (http(s), root-relative, or a raster data: URI — no SVG data URIs, no
         * protocol-relative), plus alt / numeric width+height / lazy-loading.
         * Whatever other attributes the source carried (style, srcset, class,
         * anything) do not exist in the output. An <img> with no valid src is
         * dropped entirely. */
        $html = preg_replace_callback('/<img\b[^>]*\/?>/i', function ($m) {
            $tag = $m[0];
            $pick = function (string $attr) use ($tag): string {
                if (!preg_match('/\b' . $attr . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>\/]+))/i', $tag, $am)) return '';
                foreach ([2, 3, 4] as $i) if (isset($am[$i]) && $am[$i] !== '') return html_entity_decode($am[$i], ENT_QUOTES, 'UTF-8');
                return '';
            };
            $src = trim($pick('src'));
            if ($src === '' || !preg_match('#^(https?://|/(?!/)|data:image/(png|jpe?g|gif|webp);)#i', $src)) return '';
            $out = '<img src="' . hc_esc($src) . '" alt="' . hc_esc($pick('alt')) . '"';
            $w = $pick('width');  if ($w !== '' && ctype_digit($w)) $out .= ' width="' . $w . '"';
            $h = $pick('height'); if ($h !== '' && ctype_digit($h)) $out .= ' height="' . $h . '"';
            return $out . ' loading="lazy" decoding="async">';
        }, $html) ?? $html;
        // Balance LAST: strip_tags leaves unclosed tags behind, and those are what
        // break the page. After this the fragment is guaranteed well-formed.
        return hc_article_persist_links(hc_balance_html(hc_article_image_alts($html, $articleTitle)));
    }
    return hc_article_persist_links(hc_balance_html(hc_article_image_alts(hc_md($raw), $articleTitle)));
}

/**
 * PHASE_HC_EMBED_CONTENT_LINKS_2026-09-02 — RELATIVE LINKS INSIDE ARTICLE CONTENT.
 *
 * "Related articles" and any cross-link an author writes live INSIDE the stored article
 * HTML as `href="?article=slug"`. A relative href replaces the WHOLE query string, so in
 * the Help Center widget they dropped `embed=1` and the full page loaded inside a ~470px
 * panel — the owner's "the back link now will take you to full help center". Template links
 * were fixed at their generators; these cannot be, because they are content, not markup we
 * emit. So they are rewritten at render time through hc_u(), which carries
 * embed/widget/site_key/side (and is a no-op on the public page, where $_hcPersist is empty).
 */
function hc_article_persist_links(string $html): string
{
    if ($html === '' || strpos($html, 'href="?') === false) return $html;
    return (string) preg_replace_callback(
        '/href="\?([^"]*)"/i',
        static function (array $m): string { return 'href="' . hc_esc(hc_u(html_entity_decode($m[1], ENT_QUOTES))) . '"'; },
        $html
    );
}
function hc_default_opsiq_logo(): string {
    return '<span class="hc-op-logo-mark" aria-hidden="true"><span>IQ</span></span><span class="hc-op-logo-word">OpsIQ</span>';
}

/* PHASE_HC_NAV_PREMIUM — resolve the little icon shown beside a nav menu item.
 * Priority: an explicit token from the curated set → a crisp monoline SVG; an
 * emoji or image URL → shown as-is; otherwise we DERIVE a sensible icon from the
 * label's keywords (so "Shared Hosting" gets a server, "Domains" a globe) and, if
 * nothing matches, fall back to a tidy lettered tile. Always returns inner markup
 * for a .hc-nav-mi-ic span. */
function hc_nav_icon_svg(string $tok): string {
    static $M = null;
    if ($M === null) $M = [
        'server'  => '<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>',
        'globe'   => '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/>',
        'layout'  => '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>',
        'globe2'  => '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/>',
        'mail'    => '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
        'shield'  => '<path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/>',
        'lock'    => '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
        'cloud'   => '<path d="M6.5 18a4.5 4.5 0 0 1 .3-9A6 6 0 0 1 18 9.5a3.5 3.5 0 0 1-.5 8.5z"/>',
        'cart'    => '<circle cx="9" cy="20" r="1.5"/><circle cx="18" cy="20" r="1.5"/><path d="M2 3h3l2.4 12.4a2 2 0 0 0 2 1.6h8.2a2 2 0 0 0 2-1.6L23 7H6"/>',
        'wrench'  => '<path d="M14.5 6a3.5 3.5 0 0 0-4.6 4.6L3 17.5 6.5 21l6.9-6.9A3.5 3.5 0 0 0 18 9.5L15.5 12 12 8.5 14.5 6z"/>',
        'life'    => '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><path d="M5 5l3.2 3.2M15.8 15.8 19 19M19 5l-3.2 3.2M8.2 15.8 5 19"/>',
        'tag'     => '<path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="7.5" cy="7.5" r="1.3"/>',
        'book'    => '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M4 19a2 2 0 0 1 2-2h13"/>',
        'chat'    => '<path d="M21 12a8 8 0 0 1-11.5 7.2L3 21l1.8-6.5A8 8 0 1 1 21 12z"/>',
        'rocket'  => '<path d="M5 15c-1.5 1.5-2 5-2 5s3.5-.5 5-2M9 11a10 10 0 0 1 9-6 10 10 0 0 1-6 9l-3 1z"/><circle cx="14.5" cy="9.5" r="1.4"/>',
        'sparkle' => '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/>',
        'code'    => '<path d="m8 8-4 4 4 4M16 8l4 4-4 4"/>',
        'users'   => '<circle cx="9" cy="8" r="3.2"/><path d="M3 20a6 6 0 0 1 12 0"/><path d="M16 5.2a3.2 3.2 0 0 1 0 6M21 20a6 6 0 0 0-4-5.6"/>',
        'phone'   => '<path d="M4 4h4l2 5-2.5 1.5a12 12 0 0 0 6 6L15 14l5 2v4a2 2 0 0 1-2.2 2A16 16 0 0 1 2 6.2 2 2 0 0 1 4 4z"/>',
        'doc'     => '<path d="M6 2h8l4 4v16H6z"/><path d="M14 2v4h4M9 13h6M9 17h6"/>',
        'grid'    => '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
        /* PHASE_HC_NAV_ITEMS_2026-08-14 — the set was 21 tokens, and an unrecognised one
         * draws NOTHING, so an operator typing a perfectly reasonable word ("megaphone",
         * "star", "bell") got silence and a placeholder that had promised otherwise.
         * These are the words a promo card or an announcement actually reaches for. */
        'star'    => '<path d="M12 3.5l2.6 5.6 6 .8-4.4 4.2 1.1 6.1L12 17.3 6.7 20.2l1.1-6.1L3.4 9.9l6-.8z"/>',
        'bell'    => '<path d="M18 9a6 6 0 1 0-12 0c0 6-2 7-2 7h16s-2-1-2-7"/><path d="M13.7 20a2 2 0 0 1-3.4 0"/>',
        'megaphone' => '<path d="M3 11v2a1 1 0 0 0 1 1h2l10 5V5L6 10H4a1 1 0 0 0-1 1z"/><path d="M19 9a3.5 3.5 0 0 1 0 6"/>',
        'gift'    => '<rect x="3" y="10" width="18" height="11" rx="1.5"/><path d="M3 10h18M12 10v11"/><path d="M12 10S9.5 3 7 5.5 12 10 12 10zM12 10s2.5-7 5-4.5S12 10 12 10z"/>',
        'calendar'=> '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
        'clock'   => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5.5l3.5 2"/>',
        'download'=> '<path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M4 20h16"/>',
        'play'    => '<circle cx="12" cy="12" r="9"/><path d="M10 8.5l6 3.5-6 3.5z"/>',
        'check'   => '<circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.7 2.7L16 9.5"/>',
        'warning' => '<path d="M12 3.5 22 20H2z"/><path d="M12 10v4M12 17.2h.01"/>',
        'info'    => '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
        'card'    => '<rect x="2.5" y="5" width="19" height="14" rx="2.5"/><path d="M2.5 10h19M6 15h4"/>',
        'spark'   => '<path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><path d="m5.6 5.6 4.2 4.2M14.2 14.2l4.2 4.2M18.4 5.6l-4.2 4.2M9.8 14.2l-4.2 4.2"/>',
        'search'  => '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/>'
    ];
    $p = $M[$tok] ?? ''; if ($p === '') return '';
    return '<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'.$p.'</svg>';
}
function hc_nav_derive_icon(string $label): string {
    $l = mb_strtolower($label);
    $map = [
        'wordpress'=>'globe2','word press'=>'globe2','reseller'=>'grid','vps'=>'server','dedicated'=>'server',
        'cloud'=>'cloud','host'=>'server','server'=>'server','domain'=>'globe','dns'=>'globe',
        'website'=>'layout','web site'=>'layout','site builder'=>'layout','builder'=>'layout','template'=>'layout',
        'email'=>'mail','mail'=>'mail','workspace'=>'mail','office'=>'mail',
        'ssl'=>'lock','secure'=>'shield','security'=>'shield','protect'=>'shield','backup'=>'shield',
        'store'=>'cart','shop'=>'cart','buy'=>'cart','pricing'=>'tag','price'=>'tag','plan'=>'tag','deal'=>'tag',
        'support'=>'life','help'=>'life','ticket'=>'life','contact'=>'phone','call'=>'phone',
        'doc'=>'book','guide'=>'book','knowledge'=>'book','article'=>'book','blog'=>'book','news'=>'book','announc'=>'book',
        'develop'=>'code','api'=>'code','tool'=>'wrench','manage'=>'wrench','account'=>'users','team'=>'users','partner'=>'users',
        'chat'=>'chat','community'=>'chat','forum'=>'chat','start'=>'rocket','get started'=>'rocket','new'=>'sparkle','feature'=>'sparkle'
    ];
    foreach ($map as $kw => $ico) { if (mb_strpos($l, $kw) !== false) return $ico; }
    return '';
}
function hc_nav_icon_html($iconField, string $label): string {
    $iconField = trim((string)$iconField);
    // explicit image URL
    if ($iconField !== '' && preg_match('~^(https?:)?//|^/|^data:~i', $iconField)) {
        return '<img src="'.hc_esc(hc_asset_url($iconField)).'" alt="" loading="lazy" decoding="async">';
    }
    // explicit token
    if ($iconField !== '') {
        $svg = hc_nav_icon_svg($iconField);
        if ($svg !== '') return $svg;
        // explicit emoji / short glyph
        if (mb_strlen($iconField) <= 3) return '<span class="hc-nav-mi-emo">'.hc_esc($iconField).'</span>';
    }
    // derive from label
    $svg = hc_nav_icon_svg(hc_nav_derive_icon($label));
    if ($svg !== '') return $svg;
    // lettered fallback
    $first = mb_strtoupper(mb_substr(trim($label), 0, 1));
    return '<span class="hc-nav-mi-ltr">'.hc_esc($first !== '' ? $first : '•').'</span>';
}

/* PHASE_HC_NAV_ITEMS_2026-08-14 — the mega menu's PROMO COLUMN.
 *
 * The portal's feature cards, brought over with their configuration intact: up to four
 * cards belonging to THIS menu, each choosing how much of the column's height it takes
 * (share it / full / a slim strip), where its words sit (top-middle-bottom, and
 * left-centre-right), and an optional icon above the title with its own placement and
 * size. The background image fills the card BEHIND the text — it is not the link and it
 * is not a thumbnail. A card with neither a title nor a background is not rendered.
 *
 * The button text is the card's own `cta`. It used to be a hardcoded "Explore" that no
 * operator could change; that string is gone from the renderer and from hc_i18n.
 *
 * Only the url() goes inline — the scrim that keeps the words legible over a photo is a
 * token in the stylesheet, so no colour is hardcoded here.
 */
function hc_nav_feature_cards(array $features, string $side = 'right'): string {
    $side  = $side === 'left' ? 'left' : 'right';
    $cards = array_values(array_filter(
        \OpsIQ\Kb\HcNav::features($features),
        static fn(array $c): bool => ($c['side'] ?? 'right') === $side
    ));
    if (!$cards) return '';
    $h = '<div class="hc-nav-feats hc-nav-feats-side-' . $side . ' hc-nav-feats-' . count($cards) . '">';
    foreach ($cards as $f) {
        $img = (string)$f['image'];
        $h .= '<a class="hc-nav-promo hc-nav-promo-' . hc_esc($f['size'])
            . ' hc-nav-promo-al-' . hc_esc($f['align'])
            . ' hc-nav-promo-tx-' . hc_esc($f['text_align'])
            . ($img !== '' ? ' hc-nav-promo-bg' : '')
            . '" href="' . hc_esc($f['url'] !== '' ? $f['url'] : hc_u()) . '"'
            . ($img !== '' ? ' style="background-image:url(\'' . hc_esc(hc_asset_url($img)) . '\')"' : '') . '>';
        /* an icon only when the operator asked for one — no derived fallback here, or
           every card would grow a lettered tile it was never given */
        if ((string)$f['icon'] !== '') {
            $ico = hc_nav_icon_html($f['icon'], (string)$f['title']);
            if ($ico !== '') $h .= '<span class="hc-nav-promo-i hc-nav-promo-i-' . hc_esc($f['icon_pos'])
                . ' hc-nav-promo-i-' . hc_esc($f['icon_size']) . '">' . $ico . '</span>';
        }
        if ((string)$f['title'] !== '') $h .= '<span class="hc-nav-promo-t">' . hc_esc($f['title']) . '</span>';
        if ((string)$f['desc']  !== '') $h .= '<span class="hc-nav-promo-d">' . hc_esc($f['desc']) . '</span>';
        if ((string)$f['cta']   !== '') $h .= '<span class="hc-nav-promo-go">' . hc_esc($f['cta'])
            . ' <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>';
        $h .= '</a>';
    }
    return $h . '</div>';
}

/* PHASE10K3_2026-08-11 — OPERATOR-AUTHORED chrome. When nav_custom_html /
 * footer_custom_html carry markup, they REPLACE the built-in nav / footer
 * outright. The variables below are substituted first, so a custom nav can
 * carry a working portal-SSO sign-in without hardcoding hosts:
 *   {portal_url}        the customer portal's public base
 *   {portal_login_url}  the portal sign-in page (SSO entry)
 *   {help_url}          this help center's home
 *   {site_name}         the workspace's display name
 *   {year}              the current year
 * The value is the operator's own HTML by design (same trust level as the
 * existing custom-CSS field: it only ever renders on THEIR site, written by an
 * admin holding manage_knowledge) — substituted, never escaped. */
function hc_custom_chrome(string $tpl): string {
    if (!function_exists('opsiq_portal_public_base')) {
        $__pe = ($GLOBALS['_opsiqRoot'] ?? dirname(__FILE__)) . '/opsiq/opsiq.portal_experience.php';
        if (is_file($__pe)) { try { require_once $__pe; } catch (\Throwable $e) {} }
    }
    $base = function_exists('opsiq_portal_public_base')
        ? (string)opsiq_portal_public_base((string)($GLOBALS['_siteKey'] ?? ''))
        : '';
    return strtr($tpl, [
        '{portal_url}'       => $base,
        '{portal_login_url}' => hc_portal_login_url(),
        '{help_url}'         => hc_u(),
        '{site_name}'        => (string)($GLOBALS['siteName'] ?? ''),
        '{year}'             => date('Y'),
    ]);
}

/* PHASE_HC_CHROME_AUTODARK_2026-08-24 — DOES THE OPERATOR'S OWN CHROME HANDLE DARK?
 *
 * A custom nav or footer replaces ours outright, and ours is what carries the dark
 * treatment — so on a dark page the operator's markup stayed light: a white band
 * above and below a dark page. The Portal solves the same problem by letting the
 * operator PICK a dark surface for its footer; it never asks whether their code
 * already handles dark, because the Portal's footer is always its own markup.
 * Here the markup is theirs, so asking is the whole point.
 *
 * FAIL SAFE TOWARDS LEAVING IT ALONE. Every signal below means "this author has
 * thought about dark mode", and a false positive only means we do nothing —
 * their design renders as they wrote it. A false NEGATIVE would repaint a design
 * that already had an answer, which is the worse mistake, so the list is
 * generous: a media query, a theme attribute, a `.dark` selector, a Tailwind
 * `dark:` utility, or a declared color-scheme all count. */
function hc_chrome_has_dark(string $html): bool {
    if (trim($html) === '') return false;
    $h = strtolower($html);
    foreach (['prefers-color-scheme', 'data-theme', 'color-scheme', '.dark', 'dark:', 'data-dark', 'darkmode', 'dark-mode'] as $needle) {
        if (strpos($h, $needle) !== false) return true;
    }
    return false;
}

/* The class the auto treatment hangs on. Empty when the author handles dark
 * themselves, so their markup is never touched. */
function hc_chrome_autodark_class(string $html): string {
    return hc_chrome_has_dark($html) ? '' : ' hc-chrome-autodark';
}

function hc_render_nav(): string {
    /* PHASE10K3 — the operator's own nav wins outright. $GLOBALS read on
     * purpose: this function does not import $_settings (the house trap). */
    $__navCustom = trim((string)($GLOBALS['_settings']['nav_custom_html'] ?? ''));
    if ($__navCustom !== '') {
        return '<div id="hc-hdr" class="hc-hdr-custom' . hc_chrome_autodark_class($__navCustom) . '">'
             . hc_custom_chrome($__navCustom) . '</div>';
    }
    /* $logoUrlDark MUST be listed here — this function cannot see $_settings, and a
     * global left out of this list silently renders empty. */
    global $logoUrl, $logoUrlDark, $siteName, $_navStyle, $_navMenuStyle, $_navLogoPosition, $_navLinksAlign, $_navItems, $_navCtaLabel, $_navCtaUrl, $_navCtas, $_navCtaSize, $_navCaret,
           /* PHASE_HC_DARK — the sun/moon gate. Same trap as always: leave these out
            * and the button silently never renders however the toggle is set. */
           $darkEnabled, $darkToggleNav,
           /* PHASE_HC_I18N — the language picker. Same rule. */
           $_i18nOn, $_i18nSwitcherNav, $_i18nLocales, $_locale, $__t, $_i18nSource;
    $links = is_array($_navItems) ? $_navItems : [];
    /* Dropdown indicator icon (clean SVG, not a text glyph). */
    $caretSvg = '';
    if ($_navCaret === 'caret')   $caretSvg = '<svg class="hc-caret-ico" width="9" height="6" viewBox="0 0 10 6" aria-hidden="true"><path d="M0 0h10L5 6z" fill="currentColor"/></svg>';
    elseif ($_navCaret === 'chevron') $caretSvg = '<svg class="hc-caret-ico" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
    ob_start(); ?>
<header class="hc-hdr hc-nav-<?= hc_esc($_navStyle) ?> hc-menu-<?= hc_esc($_navMenuStyle) ?> hc-caret-<?= hc_esc($_navCaret) ?> hc-logo-<?= hc_esc($_navLogoPosition) ?><?= $_navLinksAlign !== 'auto' ? ' hc-navalign-' . hc_esc($_navLinksAlign) : '' ?>" id="hc-hdr">
  <div class="hc-hdr-inner">
    <a href="<?= hc_esc(hc_u()) ?>" class="hc-logo" id="hc-logo-link">
      <?php
      /* PHASE_HC_DARK — both logos are rendered and CSS picks one on data-theme.
       * Swapping the src in JS would flash the wrong logo on every load and lose
       * the browser's preload. When no dark logo is set the light one carries both
       * (a slightly wrong logo beats a missing one). */
      /* PHASE_HC_SEO_2026-08-27 — real dimensions so the header does not reflow when the
         logo lands, and `fetchpriority=high` because this is the one image above the
         fold on every page. Deliberately NOT lazy: lazy-loading an above-the-fold image
         delays the largest paint, which is the opposite of the intent. */
      if ($logoUrl): ?><img class="hc-logo-img<?= $logoUrlDark !== '' ? ' hc-logo-light' : '' ?>" src="<?= hc_asset_url($logoUrl) ?>" alt="<?= hc_esc($siteName) ?>"<?= hc_img_dims($logoUrl) ?> decoding="async" fetchpriority="high"><?php
        if ($logoUrlDark !== ''): ?><img class="hc-logo-img hc-logo-dark" src="<?= hc_asset_url($logoUrlDark) ?>" alt="<?= hc_esc($siteName) ?>"<?= hc_img_dims($logoUrlDark) ?> decoding="async"><?php endif;
      else: ?><?= hc_default_opsiq_logo() ?><?php endif; ?>
    </a>
    <?php $__hasNav = ($links || !empty($_navCtas)); ?>
    <?php /* PHASE_HC_MOBILE_NAV — collapsible on mobile; `display:contents` keeps
       the desktop layout unchanged, the hamburger toggles the dropdown ≤820px. */ ?>
    <div class="hc-nav-collapse" id="hc-nav-collapse">
    <?php if ($links): ?>
    <nav class="hc-nav-links" aria-label="<?= hc_esc($__t("nav_help_center", "Help Center navigation")) ?>">
      <?php foreach ($links as $link): ?>
        <?php if (!empty($link['children'])):
          /* The item SAYS what it is. It used to be decided by matching the label
             against a separate list of names, so renaming a menu quietly unmade it.
             The global "Dropdown Menu Style" still promotes every dropdown. */
          $__isMega = ($_navMenuStyle === 'mega') || (($link['type'] ?? '') === 'mega');
        ?>
          <?php /* PHASE_HC_NAV_PREMIUM — premium dropdown / mega menu: each item is an
                   icon chip + title + optional description; a mega menu adds a featured
                   "overview" card on the left. Icons come from the item's icon field,
                   or are derived from the label, or fall back to a lettered tile. */ ?>
          <div class="hc-nav-drop<?= $__isMega ? ' hc-drop-mega' : '' ?>"><button type="button" aria-haspopup="true" aria-expanded="false"><span class="hc-nav-drop-label"><?= hc_esc((string)$link['label']) ?></span><?= $caretSvg ?></button><div class="hc-nav-menu">
            <?php /* PHASE_HC_NAV_OVERVIEW_TOGGLE_2026-08-27 — the overview card is opt-OUT.
                     Absent means shown, so every menu that has one today keeps it; an
                     operator who turns it off gets a mega menu that is only its columns. */ ?>
            <?php /* `!== false` and not a string test: the normaliser hands this back as a
                     real boolean, and `(string)false` is the empty string — see HcNav::flag. */ ?>
            <?php $__showFeat = $__isMega && !empty($link['url']) && (($link['overview'] ?? true) !== false); ?>
            <?php if ($__showFeat): $__fd = trim((string)($link['desc'] ?? '')); ?>
              <a class="hc-nav-feat" href="<?= hc_esc((string)$link['url']) ?>">
                <span class="hc-nav-feat-ic"><?= hc_nav_icon_html($link['icon'] ?? '', (string)$link['label']) ?></span>
                <span class="hc-nav-feat-t"><?= hc_esc((string)$link['label']) ?></span>
                <span class="hc-nav-feat-d"><?= hc_esc($__fd !== "" ? $__fd : sprintf($__t("everything_in", "Everything in %s."), (string)$link["label"])) ?></span>
                <span class="hc-nav-feat-go"><?= hc_esc($__t('browse_all', 'Browse all')) ?> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
              </a>
            <?php elseif (!empty($link['url'])): ?>
              <a class="hc-nav-overview" href="<?= hc_esc((string)$link["url"]) ?>"><?= hc_esc(sprintf($__t("overview_of", "%s overview"), (string)$link["label"])) ?></a>
            <?php endif; ?>
            <?php /* a card set to the LEFT is emitted before the links, so the flex row
                     reads: overview card · left promos · links · right promos */ ?>
            <?php if ($__isMega): ?><?= hc_nav_feature_cards((array)($link['features'] ?? []), 'left') ?><?php endif; ?>
            <?php /* PHASE_HC_NAV_COLUMNS_2026-08-14 — TITLED COLUMNS.
                     A child that has its OWN children is a column: its label is the heading
                     and its links fill the column (owner: *"or you add a child?"* — one
                     structure and one editor flow instead of a second `columns[]` beside
                     `children[]`). A menu whose children are all plain links keeps the flat
                     two-column list it has always had, so nothing changes until an operator
                     groups something. */ ?>
            <?php $__cols = $__isMega ? \OpsIQ\Kb\HcNav::columns((array)$link['children']) : []; ?>
            <?php if ($__cols): ?>
            <div class="hc-nav-mcols" style="--hc-mega-cols:<?= count($__cols) ?>">
              <?php foreach ($__cols as $__col): ?>
              <div class="hc-nav-mcol">
                <?php if (($__col['title'] ?? '') !== ''): ?><div class="hc-nav-mcol-h"><?= hc_esc((string)$__col['title']) ?></div><?php endif; ?>
                <?php foreach ($__col['links'] as $child): $__cd = trim((string)($child['desc'] ?? '')); ?>
                <a class="hc-nav-mi" href="<?= hc_esc((string)$child['url']) ?>">
                  <span class="hc-nav-mi-ic"><?= hc_nav_icon_html($child['icon'] ?? '', (string)$child['label']) ?></span>
                  <span class="hc-nav-mi-tx"><span class="hc-nav-mi-t"><?= hc_esc((string)$child['label']) ?></span><?php if ($__cd !== ''): ?><span class="hc-nav-mi-d"><?= hc_esc($__cd) ?></span><?php endif; ?></span>
                  <span class="hc-nav-mi-ar" aria-hidden="true"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></span>
                </a>
                <?php endforeach; ?>
              </div>
              <?php endforeach; ?>
            </div>
            <?php else: ?>
            <div class="hc-nav-mi-list">
              <?php foreach ($link['children'] as $child): $__cd = trim((string)($child['desc'] ?? '')); ?>
                <a class="hc-nav-mi" href="<?= hc_esc((string)$child['url']) ?>">
                  <span class="hc-nav-mi-ic"><?= hc_nav_icon_html($child['icon'] ?? '', (string)$child['label']) ?></span>
                  <span class="hc-nav-mi-tx"><span class="hc-nav-mi-t"><?= hc_esc((string)$child['label']) ?></span><?php if ($__cd !== ''): ?><span class="hc-nav-mi-d"><?= hc_esc($__cd) ?></span><?php endif; ?></span>
                  <span class="hc-nav-mi-ar" aria-hidden="true"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></span>
                </a>
              <?php endforeach; ?>
            </div>
            <?php endif; ?>
            <?php /* PHASE_HC_NAV_ITEMS_2026-08-14 — the PROMO COLUMN, the portal's
                     mega menu pattern brought over properly: up to four cards, each
                     one belonging to THIS menu. It used to be four site-wide settings,
                     so every mega menu on the site showed the same panel and the
                     button always read a hardcoded "Explore". Both are gone: the cards
                     live on the item and the button text is the card's own `cta`. */ ?>
            <?php if ($__isMega): ?><?= hc_nav_feature_cards((array)($link['features'] ?? []), 'right') ?><?php endif; ?>
          </div></div>
        <?php else: ?>
          <a href="<?= hc_esc((string)$link['url']) ?>"><?= hc_esc((string)$link['label']) ?></a>
        <?php endif; ?>
      <?php endforeach; ?>
    </nav>
    <?php endif; ?>
    <?php if ($_i18nOn && $_i18nSwitcherNav): /* PHASE_HC_I18N — the language picker.
       A native <select> on purpose: it is keyboard- and screen-reader-correct for
       free, it renders as the platform's own picker on mobile (a scrollable list of
       31 is genuinely better as a native sheet), and it needs no popup code. Names
       are shown in each language's OWN script — someone looking for 日本語 is not
       reading "Japanese". */ ?>
    <?php
      $__LL   = function_exists('opsiq_portal_locales') ? opsiq_portal_locales() : [];
      /* Only the short code is wanted from the badge helper — its first element is an emoji
       * flag, which is useless on Windows (no flag emoji ships at all, so 🇬🇧 draws as the
       * letters "GB"). Real SVG files instead, via the file-scope helper. */
      [, $__curShort] = opsiq_hc_locale_badge($_locale);
    ?>
    <div class="hc-lang" id="hc-lang">
      <?php /* The trigger is the SHORT badge — names run long (Português (BR),
               简体中文) and would shove the nav around. The full native name is in
               the menu, which is what people actually read. The links work with no
               JS at all: ?lang= is a real navigation. */ ?>
      <button type="button" class="hc-lang-btn" id="hc-lang-btn"
              aria-haspopup="true" aria-expanded="false" aria-controls="hc-lang-menu"
              aria-label="<?= hc_esc($__t('language', 'Language')) ?>">
        <?= hc_lang_flag_img($_locale) ?>
        <span class="hc-lang-code"><?= hc_esc($__curShort) ?></span>
        <svg class="hc-lang-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
      </button>
      <div class="hc-lang-menu" id="hc-lang-menu" role="menu" hidden>
        <?php foreach ($_i18nLocales as $__code):
                $__meta = $__LL[$__code] ?? null; if (!$__meta) continue;
                [$__f, $__s] = opsiq_hc_locale_badge($__code);
                /* ⚠ EVERY ITEM CARRIES ?lang=, THE SOURCE LANGUAGE INCLUDED.
                 *
                 * The source item used to link to a clean URL on the reasoning that no
                 * parameter means the default language. It does not: the resolver a few
                 * hundred lines above reads `?lang` FIRST and the `hc_lang` cookie SECOND,
                 * and that cookie holds whatever the visitor picked last. So a visitor who
                 * had ever chosen another language could never get back — clicking English
                 * navigated to a URL with nothing to say, the cookie answered "French" again,
                 * and the page came back French with the tick still on French. From the
                 * outside the picker simply did not respond, which is exactly what the owner
                 * reported: "the drop down remains on the same one".
                 *
                 * An explicit choice has to BE explicit. Naming the locale in the URL also
                 * rewrites the cookie (see the setcookie beside that resolver), so the choice
                 * sticks for the next visit instead of being undone by the old one. */
                $__href = hc_u_lang($__code);
        ?>
        <a class="hc-lang-item<?= $__code === $_locale ? ' is-active' : '' ?>" role="menuitem"
           href="<?= hc_esc($__href) ?>" data-lang="<?= hc_esc($__code) ?>"
           lang="<?= hc_esc($__code) ?>" dir="<?= hc_esc(opsiq_portal_locale_dir($__code)) ?>">
          <?= hc_lang_flag_img($__code) ?>
          <span class="hc-lang-name"><?= hc_esc((string)$__meta['native']) ?></span>
          <?php if ($__code === $_locale): ?>
          <svg class="hc-lang-tick" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
          <?php endif; ?>
        </a>
        <?php endforeach; ?>
      </div>
    </div>
    <?php endif; ?>
    <?php if ($darkEnabled && $darkToggleNav): /* PHASE_HC_DARK — the sun/moon.
       Inside .hc-nav-collapse so it folds into the burger menu on mobile like
       everything else here. Both glyphs ship and CSS shows one, so it never
       flashes the wrong icon. It calls the same OpsIQHelpTheme the paste-in
    <?php /* PHASE10K9_2026-08-11 — the nav command palette was REMOVED. The owner
             asked for the Help Center's OWN hero search bar to gain Ctrl-K and a
             premium treatment, not for a second search system to appear in the
             navigation. The shortcut now belongs to the hero search component. */ ?>
    <button type="button" class="hc-theme-tog" id="hc-theme-tog"
            aria-label="<?= hc_esc($__t("theme_switch", "Switch between light and dark")) ?>" title="<?= hc_esc($__t("theme_toggle", "Light / dark")) ?>">
      <svg class="hc-tog-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
      <svg class="hc-tog-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 1.6v2.2M12 20.2v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M1.6 12h2.2M20.2 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6"/></svg>
    </button>
    <?php endif; ?>
    <?php foreach (($_navCtas ?? []) as $__cta): ?><a class="hc-nav-cta hc-cta-<?= hc_esc($__cta['style']) ?> hc-cta-sz-<?= hc_esc($_navCtaSize) ?>" href="<?= hc_esc($__cta['url']) ?>"><?= hc_esc($__cta['label']) ?></a><?php endforeach; ?>
    </div>
    <?php if ($__hasNav): ?>
    <button type="button" class="hc-nav-burger" id="hc-nav-burger" aria-label="<?= hc_esc($__t("open_menu", "Open menu")) ?>" aria-controls="hc-nav-collapse" aria-expanded="false">
      <span></span><span></span><span></span>
    </button>
    <?php endif; ?>
  </div>
</header>
<?php return (string)ob_get_clean();
}

function hc_stats_markup(): string {
    global $showStats, $_totalArticles, $_totalCats, $__t;
    if (!$showStats || ($_totalArticles <= 0 && $_totalCats <= 0)) return '';
    ob_start(); ?>
    <div class="hc-hero-stats" aria-live="polite">
      <?php if ($_totalArticles > 0): ?><span><strong><?= number_format($_totalArticles) ?></strong> <?= hc_esc($__t($_totalArticles === 1 ? "article_one" : "article_many", $_totalArticles === 1 ? "article" : "articles")) ?></span><?php endif; ?>
      <?php if ($_totalArticles > 0 && $_totalCats > 0): ?><span class="hc-stat-sep" aria-hidden="true"></span><?php endif; ?>
      <?php if ($_totalCats > 0): ?><span><strong><?= $_totalCats ?></strong> <?= hc_esc($__t($_totalCats === 1 ? "category_one_lower" : "category_many_lower", $_totalCats === 1 ? "category" : "categories")) ?></span><?php endif; ?>
    </div>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE10K9b_2026-08-11 — THE SEARCH BAND, ITS OWN COMPONENT.
 *
 * The owner: "redesign the attached search bar so that it no longer matches the
 * chosen hero's search bar… 4 variants for the search bar", and Ctrl-K belongs
 * to THIS band — the one that appears when a page's header is set to "Search bar
 * only" — not to the hero and not to the navigation.
 *
 * Until now the band called hc_search_markup($_layout), so it wore whichever of
 * the twenty hero presentations the theme happened to use: it could not be
 * designed, because it had no design of its own. It has four now, and they are
 * deliberately different objects rather than four coats of paint:
 *
 *   pill     a single soft capsule, the field and its action reading as one key
 *   panel    a raised card with the icon in a plate and the shortcut on the right
 *   command  a dark console line: monospace prompt, hairline frame, terse button
 *   inline   no chrome at all — a ruled line under the field, for quiet pages
 */
function hc_searchband_markup(string $style): string {
    global $helpBase, $txtSearchPH, $_query, $__searchBtn, $__t;
    $s = strtolower(trim($style));
    if (!in_array($s, ['aurora', 'split', 'console', 'rule'], true)) $s = 'aurora';

    $ico = '<svg class="hcsb-ico" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7.5"/><line x1="21" y1="21" x2="16.8" y2="16.8"/></svg>';

    ob_start(); ?>
    <div class="hc-srch-wrap hcsb-shell hcsb-v-<?= hc_esc($s) ?>" role="search" data-hc-searchband="1">
      <form class="hc-search-form hcsb-form" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
        <?= hc_hidden() ?>
        <?php if ($s !== 'console'): ?><span class="hcsb-icowrap" aria-hidden="true"><?= $ico ?></span><?php endif; ?>
        <?php if ($s === 'console'): ?><span class="hcsb-prompt" aria-hidden="true">&gt;</span><?php endif; ?>
        <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" class="hcsb-input"
               placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>"
               autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>"
               aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
        <button type="submit" class="hcsb-go"><?= hc_esc($__searchBtn('Search')) ?></button>
      </form>
      <div id="hc-suggest-hero" class="hc-suggest" role="listbox" aria-label="<?= hc_esc($__t('search_suggestions', 'Search suggestions')) ?>" hidden></div>
      <div id="hc-hero-search-err" class="hc-search-err" aria-live="polite" hidden><?= hc_esc($__t('enter_search_term', 'Please enter a search term')) ?></div>
    </div>
    <?php return (string)ob_get_clean();
}

function hc_search_markup(string $variant): string {
    /* $__searchBtn MUST be imported by name: this function cannot see the outer
     * scope, and a closure left out of this list is simply undefined — every
     * layout's search button would fatal rather than fall back. */
    global $helpBase, $txtSearchPH, $_query, $__searchBtn, $__t;
    $v = preg_replace('/[^a-z0-9_-]/', '', strtolower($variant)) ?: 'nebula';
    ob_start(); ?>
    <div class="hc-srch-wrap hc-search-shell hc-search-shell-<?= hc_esc($v) ?>" role="search">
      <?php if ($v === 'minimal'): ?>
      <div class="hc-search-line-wrap">
        <form class="hc-search-form hc-search-line" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><label for="hc-hero-input"><?= hc_esc($__t("search", "Search")) ?></label>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit" aria-label="<?= hc_esc($__t("search", "Search")) ?>">↵</button>
        </form>
      <?php elseif ($v === 'obsidian'): ?>
      <div class="hc-search-terminal-wrap">
        <div class="hc-terminal-lights" aria-hidden="true"><span></span><span></span><span></span></div>
        <form class="hc-search-form hc-search-terminal" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-term-prompt">help:~$</span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('run')) ?></button>
        </form>
      <?php elseif ($v === 'prism'): ?>
      <div class="hc-search-prism-wrap">
        <form class="hc-search-form hc-search-prism" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-prism-chip"><?= hc_esc($__t("ask_docs", "Ask docs")) ?></span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Find answer')) ?></button>
        </form>
      <?php elseif ($v === 'classic'): ?>
      <div class="hc-search-classic-wrap">
        <div class="hc-search-classic-label"><?= hc_esc($__t("knowledge_base_search", "Knowledge base search")) ?></div>
        <form class="hc-search-form hc-search-classic" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-srch-ico" aria-hidden="true">⌕</span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Search')) ?></button>
        </form>
      <?php elseif ($v === 'atlas'): ?>
      <div class="hc-search-atlas-wrap">
        <div class="hc-atlas-pin" aria-hidden="true"></div>
        <form class="hc-search-form hc-search-atlas" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span><?= hc_esc($__t("route_to", "Route to")) ?></span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Go')) ?></button>
        </form>
      <?php elseif ($v === 'editorial'): ?>
      <div class="hc-search-editorial-wrap">
        <form class="hc-search-form hc-search-editorial" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-editorial-rule" aria-hidden="true"></span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Read')) ?></button>
        </form>
      <?php elseif ($v === 'command'): ?>
      <div class="hc-search-command-wrap">
        <form class="hc-search-form hc-search-command" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-command-prefix">⌘K</span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Open')) ?></button>
        </form>
      <?php elseif ($v === 'horizon'): ?>
      <div class="hc-search-horizon-wrap">
        <form class="hc-search-form hc-search-horizon" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><span><?= hc_esc($__searchBtn("Search")) ?></span></button>
        </form>
      <?php elseif ($v === 'vault'): ?>
      <div class="hc-search-vault-wrap"><form class="hc-search-form hc-search-vault" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><span class="hc-vault-lock" aria-hidden="true">◆</span><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Unlock')) ?></button></form>
      <?php elseif ($v === 'orbit'): ?>
      <div class="hc-search-orbit-wrap"><form class="hc-search-form hc-search-orbit" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('↗')) ?></button></form>
      <?php elseif ($v === 'mosaic'): ?>
      <div class="hc-search-mosaic-wrap"><form class="hc-search-form hc-search-mosaic" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><button type="submit"><?= hc_esc($__searchBtn('Search')) ?></button><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"></form>
      <?php elseif ($v === 'zenith'): ?>
      <div class="hc-search-zenith-wrap"><form class="hc-search-form hc-search-zenith" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Explore')) ?></button></form>
      <?php elseif ($v === 'runway'): ?>
      <div class="hc-search-runway-wrap"><form class="hc-search-form hc-search-runway" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><span>01</span><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Takeoff')) ?></button></form>
      <?php elseif ($v === 'ledger'): ?>
      <div class="hc-search-ledger-wrap"><form class="hc-search-form hc-search-ledger" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><label for="hc-hero-input"><?= hc_esc($__t("query", "Query")) ?></label><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Submit')) ?></button></form>
      <?php elseif ($v === 'sonar'): ?>
      <div class="hc-search-sonar-wrap"><form class="hc-search-form hc-search-sonar" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><span class="hc-sonar-dot" aria-hidden="true"></span><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Ping')) ?></button></form>
      <?php elseif ($v === 'gallery'): ?>
      <div class="hc-search-gallery-wrap"><form class="hc-search-form hc-search-gallery" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('View')) ?></button></form>
      <?php elseif ($v === 'stack'): ?>
      <div class="hc-search-stack-wrap"><form class="hc-search-form hc-search-stack" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Search stack')) ?></button></form>
      <?php elseif ($v === 'sanctuary'): ?>
      <div class="hc-search-sanctuary-wrap"><form class="hc-search-form hc-search-sanctuary" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?><input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false"><button type="submit"><?= hc_esc($__searchBtn('Begin')) ?></button></form>
      <?php elseif ($v === 'aurora'): ?>
      <div class="hc-search-dock-wrap">
        <form class="hc-search-form hc-search-dock" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><div class="hc-dock-icon" aria-hidden="true">?</div>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Search')) ?></button>
        </form>
      <?php else: ?>
      <div class="hc-srch-glass-wrap">
        <form class="hc-search-form hc-srch-glass" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off">
          <?= hc_hidden() ?><span class="hc-srch-ico" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
          <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($txtSearchPH) ?>" value="<?= hc_esc($_query) ?>" autofocus autocomplete="off" aria-label="<?= hc_esc($txtSearchPH) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
          <button type="submit"><?= hc_esc($__searchBtn('Search')) ?></button>
        </form>
      <?php endif; ?>
        <div id="hc-suggest-hero" class="hc-suggest" role="listbox" aria-label="<?= hc_esc($__t("search_suggestions", "Search suggestions")) ?>" hidden></div>
        <div id="hc-hero-search-err" class="hc-search-err" aria-live="polite" hidden><?= hc_esc($__t("enter_search_term", "Please enter a search term")) ?></div>
      </div>
    </div>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE_HC_HERO_SEARCH_WIDTH — an invisible copy of the CONFIGURED hero heading,
 * emitted inside the hero <h1> on pages whose title is something else (a
 * category, an article, a search term).
 *
 * Why: `.hc-hero` is a grid with place-items:center, so its content column is a
 * grid item that shrink-wraps to its widest line — the <h1>. That made the
 * search bar as wide as the page TITLE, so a short category name shrank it (568
 * vs 763 on prism; 12 of the 20 themes did it). The hero designs themselves are
 * deliberate, so nothing here forces the column wider: the sizer just keeps its
 * max-content width the same as on the home page. It is zero-height, hidden and
 * aria-hidden, so it changes nothing but the measurement.
 */
function hc_hero_sizer(): string {
    global $txtHeroHeading, $_heroHeadingBase;
    $base = (string)($_heroHeadingBase ?? '');
    if ($base === '' || $base === (string)$txtHeroHeading) return '';
    return '<span class="hc-hero-sizer" aria-hidden="true">' . hc_esc($base) . '</span>';
}

/** The same sizer for the hero sub — see hc_hero_sizer(). Themes that keep the
 *  h1 outside the search panel (runway) are measured by their SUBTITLE instead. */
function hc_hero_sub_sizer(): string {
    global $txtHeroSub, $_heroSubBase;
    $base = (string)($_heroSubBase ?? '');
    if ($base === '' || $base === (string)$txtHeroSub) return '';
    return '<span class="hc-hero-sizer" aria-hidden="true">' . hc_esc($base) . '</span>';
}

function hc_render_hero(): string {
    global $_layout, $siteName, $txtHeroHeading, $txtHeroSub, $_heroHeadingBase, $_heroSubBase, $__t,
           $__usePortalHero, $__pd, $helpBase;
    if (!empty($__usePortalHero) && is_array($__pd ?? null)
        && function_exists('opsiq_portal_render_hero') && function_exists('opsiq_portal_render_search')) {
        $style = (string)($__pd['blocks']['hero']['style'] ?? 'classic');
        if (!in_array($style, ['classic','aurora','split','card','banner','editorial','spotlight','arc','tiles','minimal','command','gateway','atlas','signal','journey'], true)) $style = 'classic';
        /* PHASE_HC_PORTAL_HERO_SUBPAGE — the category/article branch in
         * hc_build_view() sets $txtHeroHeading/$txtHeroSub to the category name
         * and description before calling this, exactly as it does for the
         * Help Center's own heroes. The Portal hero renders from $__pd, so
         * without this it ignored both and every subpage showed the Portal's
         * HOME heading ("We are here to help") instead of the category. Only
         * override when a subpage actually changed them, so the home page keeps
         * the operator's configured hero copy untouched. */
        $__pdHero = $__pd;
        if ((string)$txtHeroHeading !== '' && (string)$txtHeroHeading !== (string)$_heroHeadingBase) {
            $__pdHero['blocks']['hero']['heading'] = (string)$txtHeroHeading;
        }
        if ((string)$txtHeroSub !== '' && (string)$txtHeroSub !== (string)$_heroSubBase) {
            $__pdHero['blocks']['hero']['subheading'] = (string)$txtHeroSub;
        }
        $hero = opsiq_portal_render_hero($__pdHero);
        $search = opsiq_portal_render_search($__pd);
        /* Adapt the Portal search DOM to the Help Center's proven autocomplete
         * contract. IDs are changed before output so there can only be one live
         * result portal, even after soft navigation swaps the page body. */
        $search = str_replace(
            ['id="px-hero-search"', 'id="px-hero-search-results" class="psearch-res"', 'aria-controls="px-hero-search-results"', 'type="text"'],
            ['id="hc-hero-input" name="q" data-hc-search="hero"', 'id="hc-suggest-hero" class="psearch-res hc-suggest"', 'aria-controls="hc-suggest-hero"', 'type="search"'],
            $search
        );
        $portalBase = function_exists("opsiq_portal_public_base")
            ? opsiq_portal_public_base((string)($GLOBALS['_siteKey'] ?? ''))
            : "";

        /* PHASE10K9_2026-08-11 — THE PORTAL HERO'S OWN TYPEFACE.
         *
         * The owner, comparing the two pages side by side: "the fonts are
         * different… something is making it bigger." Measured, both headings are
         * 62px/850 — but the Portal renders in the workspace's Raleway and /hc
         * rendered in Proxima Nova, and a different face at the same size reads
         * bigger and wraps onto a second line.
         *
         * Cause: the Portal's theme CSS declares --portal-font-h, and
         * `.phero-h{font-family:var(--portal-font-h)!important}` depends on it.
         * /hc rendered the Portal's MARKUP without the Portal's TOKENS, so that
         * var resolved to nothing and the heading fell back to the Help Center's
         * font. The tokens are declared on the hero SECTION, not on :root: the
         * Portal's palette names (--ink, --card, --bg, --accent) are the same
         * names the Help Center uses, and publishing those globally would
         * repaint the whole page. Scoped here, only the hero is affected —
         * which is exactly what "use the Portal hero" should mean. */
        $__heroVars = '';
        if (function_exists('opsiq_portal_render_theme_css')) {
            /* Lift the values out of the PORTAL'S OWN emitted CSS rather than
             * re-deriving them here. The portal resolves a face from a preset, a
             * display/body pairing and two custom-family overrides; a second
             * implementation of that would be right today and wrong the first
             * time somebody changes a pairing. */
            $__themeCss = (string) opsiq_portal_render_theme_css($__pd);
            $__decls = '';
            if (preg_match('/--portal-font\s*:\s*([^;}]+)/', $__themeCss, $m1)) {
                $__decls .= '--portal-font:' . trim($m1[1]) . ';';
            }
            if (preg_match('/--portal-font-h\s*:\s*([^;}]+)/', $__themeCss, $m2)) {
                $__decls .= '--portal-font-h:' . trim($m2[1]) . ';';
            }
            /* The pairing's webfont arrives by @import, and an @import is only
             * honoured at the TOP of a stylesheet — hence its own element. */
            $__imp = '';
            if (preg_match('/@import url\([^)]+\);/', $__themeCss, $m3)) $__imp = $m3[0];
            if ($__decls !== '') {
                $__heroVars = ($__imp !== '' ? '<style>' . $__imp . '</style>' : '')
                    . '<style>#hc-portal-hero{' . $__decls . '}'
                    /* The static half of this lives in hc-sheet-6.css; only the
                     * two resolved font values are dynamic, so only they are
                     * inline. The page's inline-CSS budget is a ratchet. */
                    . '</style>';
            }
        }
        return $__heroVars . "<section id=\"hc-portal-hero\" class=\"hc-portal-hero\" data-hc-hero-source=\"portal\""
            . " data-help-base=\"" . hc_esc((string)$helpBase) . "\" data-portal-base=\"" . hc_esc($portalBase) . "\""
            . " aria-label=\"" . hc_esc($__t("help_center_search", "Help Center search")) . "\"><div class=\"phero-unit phu-" . hc_esc($style) . "\">"
            . $hero . $search
            . "<div id=\"hc-hero-search-err\" class=\"hc-search-err\" aria-live=\"polite\" hidden>" . hc_esc($__t("enter_search_term", "Please enter a search term")) . "</div>"
            . "</div></section>";
    }
    $layout = $_layout;
    $stats = hc_stats_markup();
    $search = hc_search_markup($layout);
    ob_start();
    switch ($layout) {
        case 'aurora': ?>
<section id="hc-hero" class="hc-hero hc-hero-aurora" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>">
  <svg class="hc-aurora-svg" viewBox="0 0 1200 520" preserveAspectRatio="none" aria-hidden="true"><defs><linearGradient id="hcAuroraWave" x1="0" x2="1"><stop offset="0" stop-color="#2dd4bf" stop-opacity=".32"/><stop offset=".55" stop-color="#60a5fa" stop-opacity=".22"/><stop offset="1" stop-color="#f472b6" stop-opacity=".30"/></linearGradient><pattern id="hcAuroraGrid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="rgba(15,23,42,.12)" stroke-width="1"/></pattern></defs><rect width="1200" height="520" fill="url(#hcAuroraGrid)"/><path d="M0 328C162 236 270 392 436 298C606 202 742 210 900 292C1030 358 1110 270 1200 210V520H0Z" fill="url(#hcAuroraWave)"/><path d="M0 118C220 62 294 170 470 118C690 54 780 112 950 78C1050 58 1128 42 1200 74" fill="none" stroke="rgba(255,255,255,.62)" stroke-width="2"/></svg>
  <div class="hc-aurora-floaters" aria-hidden="true"><span></span><span></span><span></span></div>
  <div class="hc-aurora-shell"><div class="hc-aurora-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $stats ?></div><aside class="hc-aurora-search-card"><div class="hc-aurora-card-top"><span></span><span></span><span></span></div><?= $search ?><div class="hc-aurora-glow" aria-hidden="true"></div></aside></div>
</section>
<?php break;
        case 'minimal': ?>
<section id="hc-hero" class="hc-hero hc-hero-minimal" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>">
  <svg class="hc-lumen-svg" viewBox="0 0 1200 360" preserveAspectRatio="none" aria-hidden="true"><defs><pattern id="hcLumenLines" width="80" height="80" patternUnits="userSpaceOnUse"><path d="M0 79.5H80M79.5 0V80" stroke="rgba(24,23,19,.10)" stroke-width="1"/><circle cx="80" cy="80" r="2" fill="rgba(24,23,19,.22)"/></pattern></defs><rect width="1200" height="360" fill="url(#hcLumenLines)"/><path d="M160 300L1020 58" stroke="rgba(24,23,19,.18)" stroke-width="1.5"/><path d="M192 118H860" stroke="rgba(24,23,19,.12)" stroke-width="1"/></svg>
  <div class="hc-lumen-layout"><div class="hc-lumen-index"><span>01</span><strong><?= hc_esc($siteName) ?></strong></div><div class="hc-lumen-title"><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div class="hc-lumen-search"><?= $search ?><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $stats ?></div></div>
</section>
<?php break;
        case 'obsidian': ?>
<section id="hc-hero" class="hc-hero hc-hero-obsidian" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>">
  <svg class="hc-obsidian-svg" viewBox="0 0 1200 560" preserveAspectRatio="none" aria-hidden="true"><defs><pattern id="hcObsidianGrid" width="44" height="44" patternUnits="userSpaceOnUse"><path d="M44 0H0V44" fill="none" stroke="rgba(148,163,184,.12)" stroke-width="1"/></pattern><radialGradient id="hcObsidianGlow" cx="72%" cy="22%" r="48%"><stop offset="0" stop-color="#38bdf8" stop-opacity=".30"/><stop offset="1" stop-color="#020617" stop-opacity="0"/></radialGradient></defs><rect width="1200" height="560" fill="url(#hcObsidianGrid)"/><rect width="1200" height="560" fill="url(#hcObsidianGlow)"/><path d="M0 420L1200 292V560H0Z" fill="rgba(2,6,23,.58)"/></svg>
  <div class="hc-obsidian-frame"><div class="hc-obsidian-copy"><span class="hc-hero-pill"><span class="hc-hero-dot"></span><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div class="hc-obsidian-console"><div class="hc-obsidian-bezel" aria-hidden="true"><span></span><span></span><span></span></div><?= $search ?><?= $stats ?></div></div>
</section>
<?php break;
        case 'prism': ?>
<section id="hc-hero" class="hc-hero hc-hero-prism" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-prism-ribbon"><span><?= hc_esc($siteName) ?></span><span><?= hc_esc($__t("knowledge", "Knowledge")) ?></span><span><?= hc_esc($__t("support", "Support")) ?></span></div><div class="hc-prism-stack"><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'classic': ?>
<section id="hc-hero" class="hc-hero hc-hero-classic" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-classic-panel"><div><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div class="hc-classic-search-box"><?= $search ?><?= $stats ?></div></div></section>
<?php break;
        case 'atlas': ?>
<section id="hc-hero" class="hc-hero hc-hero-atlas" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-atlas-map" aria-hidden="true"><span></span><span></span><span></span></div><div class="hc-atlas-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'editorial': ?>
<section id="hc-hero" class="hc-hero hc-hero-editorial" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-editorial-date"><?= hc_date_local(time(), 'MMMM y') ?></div><div class="hc-editorial-mast"><span><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div class="hc-editorial-bottom"><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?></div><?= $stats ?></section>
<?php break;
        case 'command': ?>
<section id="hc-hero" class="hc-hero hc-hero-command" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-command-grid"><div class="hc-command-title"><span class="hc-hero-pill"><?= hc_esc($__t("docs", "Docs")) ?> / <?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div class="hc-command-palette"><?= $search ?><div class="hc-command-lines" aria-hidden="true"><span></span><span></span><span></span></div><?= $stats ?></div></div></section>
<?php break;
        case 'horizon': ?>
<section id="hc-hero" class="hc-hero hc-hero-horizon" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-horizon-track"><div class="hc-horizon-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div class="hc-horizon-search"><?= $search ?><?= $stats ?></div></div></section>
<?php break;
        case 'vault': ?>
<section id="hc-hero" class="hc-hero hc-hero-vault" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-vault-door" aria-hidden="true"><span></span></div><div class="hc-vault-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'orbit': ?>
<section id="hc-hero" class="hc-hero hc-hero-orbit" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-orbit-system" aria-hidden="true"><span></span><span></span><span></span></div><div class="hc-orbit-core"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'mosaic': ?>
<section id="hc-hero" class="hc-hero hc-hero-mosaic" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-mosaic-wall"><div><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div><?= $search ?></div><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><div><?= $stats ?></div></div></section>
<?php break;
        case 'zenith': ?>
<section id="hc-hero" class="hc-hero hc-hero-zenith" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-zenith-topline"><?= hc_esc($siteName) ?></div><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></section>
<?php break;
        case 'runway': ?>
<section id="hc-hero" class="hc-hero hc-hero-runway" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-runway-line" aria-hidden="true"></div><div class="hc-runway-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div class="hc-runway-panel"><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'ledger': ?>
<section id="hc-hero" class="hc-hero hc-hero-ledger" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-ledger-sheet"><header><span><?= hc_esc($siteName) ?></span></header><main><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?></main><footer><?= $stats ?></footer></div></section>
<?php break;
        case 'sonar': ?>
<section id="hc-hero" class="hc-hero hc-hero-sonar" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-sonar-radar" aria-hidden="true"></div><div class="hc-sonar-copy"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        case 'gallery': ?>
<section id="hc-hero" class="hc-hero hc-hero-gallery" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-gallery-frame"><div class="hc-gallery-card"><span><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div class="hc-gallery-card search-card"><?= $search ?></div><div class="hc-gallery-card"><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $stats ?></div></div></section>
<?php break;
        case 'stack': ?>
<section id="hc-hero" class="hc-hero hc-hero-stack" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-stack-cards"><div><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1></div><div><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div><?= $search ?><?= $stats ?></div></div></section>
<?php break;
        case 'sanctuary': ?>
<section id="hc-hero" class="hc-hero hc-hero-sanctuary" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-sanctuary-wrap"><span class="hc-hero-pill"><?= hc_esc($siteName) ?></span><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p><?= $search ?><?= $stats ?></div></section>
<?php break;
        default: ?>
<section id="hc-hero" class="hc-hero hc-hero-nebula" aria-label="<?= hc_esc($__t("help_center_search", "Help Center search")) ?>"><div class="hc-nebula-grid"><div class="hc-nebula-copy"><div class="hc-hero-pill" aria-hidden="true"><span class="hc-hero-dot"></span><?= hc_esc($siteName) ?></div><h1><?= hc_esc($txtHeroHeading) ?><?= hc_hero_sizer() ?></h1><p class="hc-hero-sub"><?= hc_esc($txtHeroSub) ?><?= hc_hero_sub_sizer() ?></p></div><div class="hc-nebula-search"><?= $search ?><?= $stats ?></div></div></section>
<?php }
    return (string)ob_get_clean();
}

/**
 * PHASE1_2026-08-06 — the page-header band, rebuilt on the PORTAL's proven pattern.
 *
 * WHY THIS IS NOT THE HERO RENDERER SHRUNK.
 * That idea was tried in this codebase, on the Portal, and failed. From its
 * post-mortem: opsiq_portal_render_subhero() used to call the landing-hero
 * renderer "on the theory that one renderer = the two surfaces cannot drift.
 * That theory was wrong and cost a whole session: a landing hero is a tall
 * centred stage, and 21 styles arrange the UNIT itself... Compressed into a page
 * header they gave 21 different heights, 4 different left gutters, and pushed the
 * search bar out of the row." Its test suite now fails if the band is ever
 * reconnected to the hero renderer.
 *
 * The Help Center's twenty heroes have exactly that property — nebula is a grid,
 * obsidian a terminal, minimal a left rule, mosaic a tiled field — so shrinking
 * them would yield twenty different bands, which is the opposite of the ask.
 *
 * So this copies what the Portal settled on:
 *   - the band is its OWN component with ONE structure, so every theme gets the
 *     same height, the same gutter and the same row;
 *   - the theme contributes PAINT ONLY, as a `hcsk-<layout>` skin — a fill, no
 *     geometry, so a skin can never move the row;
 *   - the title and the search sit in ONE row, vertically centred, and the search
 *     is the SAME markup the full hero uses;
 *   - tone is decided HERE in PHP, so search-field legibility is one pair of CSS
 *     rules instead of twenty patches.
 *
 * Breadcrumbs sit UNDER the title, per the owner.
 */
/**
 * PHASE2_2026-08-06 — the "Still need help?" sidebar card.
 *
 * Owner: "some card that can be enable under side bar like cta that says still need
 * help? contact us now and button, separate from the side bar and stylable."
 *
 * So it is its OWN module, not part of the rail: it renders under the rail (or on
 * its own when the rail is off), has its own toggle, its own copy, and four style
 * variants. Copy falls back to the translated defaults, so switching it on gives
 * something sensible before the operator types anything.
 *
 * Colour comes from the theme, never hardcoded — `brand` uses the brand token, the
 * rest use card/line tokens, so it follows the Colour Studio like everything else.
 */
function hc_render_help_card(): string {
    global $__t, $_settings, $txtContactUrl;

    $on = !in_array(strtolower((string)($_settings['sidebar_help_card'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
    if (!$on) return '';

    $title = trim((string)($_settings['sidebar_help_card_title'] ?? ''));
    $text  = trim((string)($_settings['sidebar_help_card_text'] ?? ''));
    $label = trim((string)($_settings['sidebar_help_card_label'] ?? ''));
    $url   = trim((string)($_settings['sidebar_help_card_url'] ?? ''));
    $style = (string)($_settings['sidebar_help_card_style'] ?? 'soft');

    if ($title === '') $title = $__t('still_need_help', 'Still need help?');
    if ($text  === '') $text  = $__t('help_card_text', 'Our team is here if you cannot find what you are looking for.');
    if ($label === '') $label = $__t('contact_support', 'Contact us');
    /* No URL configured falls back to the contact link the rest of the page uses,
     * so the button is never a dead end. */
    if ($url === '') $url = (string)($txtContactUrl ?? '');
    if ($url === '') return '';

    $ext = (bool)preg_match('~^https?://~i', $url);

    ob_start(); ?>
    <div class="hc-help-card hc-help-card-<?= hc_esc(preg_replace('/[^a-z]/', '', $style) ?: 'soft') ?>">
      <?php /* A mark, not a decoration: it is what makes the card read as a card at a
              glance in a rail full of links. Inline SVG so it inherits currentColor and
              needs no icon font. */ ?>
      <span class="hc-help-card-ico" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
          <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
        </svg>
      </span>
      <div class="hc-help-card-body">
        <div class="hc-help-card-title"><?= hc_esc($title) ?></div>
        <p class="hc-help-card-text"><?= hc_esc($text) ?></p>
        <a class="hc-help-card-btn" href="<?= hc_esc($url) ?>"<?= $ext ? ' target="_blank" rel="noopener noreferrer"' : '' ?>>
          <span><?= hc_esc($label) ?></span>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
        </a>
      </div>
    </div>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE2_2026-08-06 — the QUICK LINKS rail card.
 *
 * Owner: "quick links different style". The home page already has a quick-links
 * module (home_quick_*); this is its rail counterpart and deliberately does NOT
 * share those settings — the home strip is a wide tiled bar and the rail card is
 * a narrow stacked list, so one item set serving both would force whichever
 * surface lost the argument to look wrong.
 *
 * It reuses the HELP CARD's shell (.hc-help-card + its six style variants)
 * instead of growing a parallel set. Three rail modules with six looks each
 * would otherwise mean eighteen variants to keep in step; this way the style
 * dropdowns stay one vocabulary and a fix to the shell reaches all of them.
 */
function hc_render_quick_card(): string {
    global $__t, $_settings;

    $on = !in_array(strtolower((string)($_settings['sidebar_quick_card'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
    if (!$on) return '';

    $items = [];
    $raw = trim((string)($_settings['sidebar_quick_card_items'] ?? ''));
    if ($raw !== '') {
        $dec = json_decode($raw, true);
        if (is_array($dec)) {
            foreach ($dec as $it) {
                if (!is_array($it)) continue;
                $lbl = trim((string)($it['label'] ?? ''));
                $url = trim((string)($it['url'] ?? ''));
                if ($lbl === '' || $url === '') continue;   // a label with no target is not a link
                $items[] = ['label' => $lbl, 'url' => $url];
            }
        }
    }
    /* No items means no card. An empty shell with a heading is worse than nothing:
     * it reads as broken rather than as unconfigured. */
    if (!$items) return '';

    $title = trim((string)($_settings['sidebar_quick_card_title'] ?? ''));
    if ($title === '') $title = $__t('quick_links', 'Quick links');
    $style = preg_replace('/[^a-z]/', '', (string)($_settings['sidebar_quick_card_style'] ?? 'bordered')) ?: 'bordered';

    ob_start(); ?>
    <div class="hc-help-card hc-help-card-<?= hc_esc($style) ?> hc-side-quick">
      <div class="hc-help-card-body">
        <div class="hc-help-card-title"><?= hc_esc($title) ?></div>
        <ul class="hc-side-quick-list">
          <?php foreach ($items as $it):
            $ext = (bool)preg_match('~^https?://~i', $it['url']); ?>
            <li>
              <a href="<?= hc_esc($it['url']) ?>"<?= $ext ? ' target="_blank" rel="noopener noreferrer"' : '' ?>>
                <span><?= hc_esc($it['label']) ?></span>
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
              </a>
            </li>
          <?php endforeach; ?>
        </ul>
      </div>
    </div>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE2_2026-08-06 — the CONTACT CHANNELS rail card.
 *
 * Owner: "contact channels". Each channel is its own optional field, so an
 * operator who only publishes an email gets a one-row card rather than a grid
 * of placeholders.
 *
 * Channels are rendered as real protocol links (mailto:, tel:, wa.me) so they
 * work on a phone without JavaScript. The chat row is the exception: it opens
 * the existing widget rather than navigating, and is only offered when a widget
 * is actually on the page — a "Start a chat" row that does nothing is a worse
 * outcome than not offering chat at all.
 */
function hc_render_channels_card(): string {
    global $__t, $_settings;

    $on = !in_array(strtolower((string)($_settings['sidebar_channels_card'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
    if (!$on) return '';

    $email = trim((string)($_settings['sidebar_channels_email'] ?? ''));
    $phone = trim((string)($_settings['sidebar_channels_phone'] ?? ''));
    $wa    = trim((string)($_settings['sidebar_channels_whatsapp'] ?? ''));

    $rows = [];
    if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $rows[] = ['mailto:' . rawurlencode($email), $__t('email_us', 'Email us'), $email, 'mail'];
    }
    if ($phone !== '') {
        /* tel: tolerates spaces and brackets in the DISPLAY text but not in the
         * target, so the two are derived separately from one field. */
        $telHref = preg_replace('~[^0-9+]~', '', $phone);
        if ($telHref !== '') $rows[] = ['tel:' . $telHref, $__t('call_us', 'Call us'), $phone, 'phone'];
    }
    if ($wa !== '') {
        $waNum = preg_replace('~[^0-9]~', '', $wa);
        if ($waNum !== '') $rows[] = ['https://wa.me/' . $waNum, $__t('whatsapp', 'WhatsApp'), $wa, 'chat'];
    }
    if (!$rows) return '';

    $title = trim((string)($_settings['sidebar_channels_card_title'] ?? ''));
    if ($title === '') $title = $__t('contact_channels', 'Contact us');
    $style = preg_replace('/[^a-z]/', '', (string)($_settings['sidebar_channels_card_style'] ?? 'soft')) ?: 'soft';

    $icons = [
        'mail'  => '<path d="M4 4h16v16H4z"/><path d="m4 7 8 6 8-6"/>',
        'phone' => '<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 1.9.7 2.8a2 2 0 0 1-.5 2.1L8.1 9.9a16 16 0 0 0 6 6l1.3-1.2a2 2 0 0 1 2.1-.5c.9.4 1.8.6 2.8.7a2 2 0 0 1 1.7 2z"/>',
        'chat'  => '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 17 0z"/>',
    ];

    ob_start(); ?>
    <div class="hc-help-card hc-help-card-<?= hc_esc($style) ?> hc-side-channels">
      <div class="hc-help-card-body">
        <div class="hc-help-card-title"><?= hc_esc($title) ?></div>
        <ul class="hc-side-channel-list">
          <?php foreach ($rows as [$href, $label, $value, $icon]):
            $ext = (bool)preg_match('~^https?://~i', $href); ?>
            <li>
              <a href="<?= hc_esc($href) ?>"<?= $ext ? ' target="_blank" rel="noopener noreferrer"' : '' ?>>
                <span class="hc-side-channel-ico" aria-hidden="true">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><?= $icons[$icon] ?></svg>
                </span>
                <span class="hc-side-channel-copy">
                  <span class="hc-side-channel-label"><?= hc_esc($label) ?></span>
                  <span class="hc-side-channel-value"><?= hc_esc($value) ?></span>
                </span>
              </a>
            </li>
          <?php endforeach; ?>
        </ul>
      </div>
    </div>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE10K9d_2026-08-11 — THE PORTAL SUB-HERO'S OWN STYLESHEET, LIFTED.
 *
 * The band's markup comes from the Portal's renderer, but its CSS lives in
 * portal-core.css, which /hc does not load — so the band arrived unstyled: the
 * heading ran to the screen edge because `.psh-in`'s container (max-width, auto
 * margins, padding, min-height) was simply not there.
 *
 * Loading portal-core.css whole is not an option: it opens with a `:root` block
 * declaring --accent, --ink, --card and --bg, the same names the Help Center
 * uses, so it would repaint the entire page. Instead the sub-hero's OWN rules
 * are lifted out of that file — every rule whose selector names `.psubhero` or
 * a `.psh-` part, media queries included. They are class-scoped to markup only
 * the band has, so nothing else on the page can match them, and because they
 * are read from the Portal's file rather than retyped here, a change on the
 * Portal reaches /hc without anyone remembering to copy it.
 */
function hc_portal_subhero_css(): string {
    static $cache = null;
    if ($cache !== null) return $cache;
    $cache = '';

    $path = __DIR__ . '/opsiq/assets/portal-core.css';
    $css  = is_file($path) ? (string) @file_get_contents($path) : '';
    if ($css === '') return $cache;

    $wanted = static fn (string $sel): bool =>
        strpos($sel, '.psubhero') !== false || strpos($sel, '.psh-') !== false;

    $out = '';
    /* Top-level rules. The @media blocks are cut out FIRST: their inner rules also match this
     * pattern, so they were copied here unconditionally as well, and the band wore its phone
     * layout at every width (search forced onto its own full-width row, tighter gaps). The
     * media pass below re-adds them inside their queries. */
    $__topCss = (string)preg_replace('/@media[^{]+\{(?:[^{}]*\{[^{}]*\})+\s*\}/', '', $css);
    if (preg_match_all('/([^{}@]+)\{([^{}]*)\}/', $__topCss, $m, PREG_SET_ORDER)) {
        foreach ($m as $r) {
            $sel = trim(preg_replace('/\s+/', ' ', $r[1]));
            if ($sel === '' || !$wanted($sel)) continue;
            $out .= $sel . '{' . trim($r[2]) . '}';
        }
    }
    /* Media queries, keeping only the band's rules inside them. */
    if (preg_match_all('/@media([^{]+)\{((?:[^{}]*\{[^{}]*\})+)\}/', $css, $mm, PREG_SET_ORDER)) {
        foreach ($mm as $blk) {
            $inner = '';
            if (preg_match_all('/([^{}]+)\{([^{}]*)\}/', $blk[2], $ir, PREG_SET_ORDER)) {
                foreach ($ir as $r) {
                    $sel = trim(preg_replace('/\s+/', ' ', $r[1]));
                    if ($sel === '' || !$wanted($sel)) continue;
                    $inner .= $sel . '{' . trim($r[2]) . '}';
                }
            }
            if ($inner !== '') $out .= '@media' . trim($blk[1]) . '{' . $inner . '}';
        }
    }

    if ($out === '') return $cache;
    /* /hc ONLY (owner, 2026-09-16: "remove 10-15px from the sub hero height, for /hc alone"):
     * 12px off the band, 6px from each side of its padding, at every Band height. The portal's own
     * pages keep the Studio heights. Scoped to .hc-subhero-portal, which only /hc emits. */
    $out .= '.hc-subhero-portal .psubhero .psh-in{min-height:calc(var(--psh-min) - 12px);padding-top:calc(var(--psh-pad) - 6px);padding-bottom:calc(var(--psh-pad) - 6px)}';
    $cache = '<style id="hc-portal-subhero-css">' . $out . '</style>';
    return $cache;
}

/**
 * PHASE10K9e_2026-08-11 — the page's breadcrumb, as the Portal's band wants it.
 *
 * One builder, used by the Portal band. The Help Center's own band builds the
 * same trail inline; this exists because the Portal band is assembled from the
 * Portal's markup and needs the trail handed to it, exactly as portal.php hands
 * it over on the Portal side.
 */
function hc_subhero_crumb_html(): string {
    global $__t, $_article, $_category;
    $parts = [];
    $parts[] = '<a href="' . hc_esc(hc_u()) . '">' . hc_esc($__t('home', 'Help Center')) . '</a>';

    if ($_category && function_exists('hc_cat_ancestors')) {
        foreach (hc_cat_ancestors($_category) as $__anc) {
            $parts[] = '<span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>'
                . '<a href="' . hc_esc(hc_u('cat=' . urlencode((string)($__anc['slug'] ?? '')))) . '">'
                . hc_esc((string)($__anc['name'] ?? '')) . '</a>';
        }
    }
    if (is_array($_category)) {
        $parts[] = '<span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>'
            . ($_article
                ? '<a href="' . hc_esc(hc_u('cat=' . urlencode((string)($_category['slug'] ?? '')))) . '">'
                    . hc_esc((string)($_category['name'] ?? '')) . '</a>'
                : '<span aria-current="page">' . hc_esc((string)($_category['name'] ?? '')) . '</span>');
    }
    if (is_array($_article)) {
        $parts[] = '<span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>'
            . '<span aria-current="page">' . hc_esc((string)($_article['page_title'] ?? '')) . '</span>';
    }
    if (count($parts) < 2) return '';

    return '<nav class="psh-crumb hc-crumb" aria-label="' . hc_esc($__t('breadcrumb', 'Breadcrumb')) . '">'
        . implode('', $parts) . '</nav>';
}

/**
 * PHASE10K11_2026-08-11 — RELATED ARTICLES, ONE BUILDER, TWO HOMES.
 *
 * The owner: "when the sidebar is turned off, Related Articles must NOT
 * disappear" — it moves into the reading column, directly after the rating.
 * Before this it was written inside the sidebar's own markup, so switching the
 * rail off deleted the section outright: an operator lost content by changing a
 * layout option, which is never what a layout option should do.
 *
 * The section is built here ONCE and placed in whichever home is available, so
 * both placements carry the same rule (count, sort, featured-only, tag filter),
 * the same wording override and the same card styling. $where changes only the
 * wrapper: a sidebar card in the rail, a full-width block in the column.
 */
function hc_related_markup(string $where = 'side'): string {
    global $_related, $__t, $__k7Cfg;

    if (!function_exists('hc_rule_apply') || !hc_shows('related_articles')) return '';
    $rel = hc_rule_apply($_related, 'related_articles', 'articles');
    if (empty($rel['items'])) return '';

    $cfg   = is_array($__k7Cfg ?? null) ? $__k7Cfg : (array)($GLOBALS['_settings'] ?? []);
    $label = trim((string)($cfg['text_related_label'] ?? ''));
    if ($label === '') $label = (string)$__t('related', 'Related articles');

    $inColumn = ($where === 'column');
    ob_start(); ?>
    <div class="<?= $inColumn ? 'hc-rel-block' : 'hc-side-card' ?>" data-hc-related="<?= $inColumn ? 'column' : 'side' ?>">
      <div class="hc-side-title hc-side-title-related"><?= hc_esc($label) ?></div>
      <ul class="hc-rel-list" role="list">
        <?php foreach ($rel['items'] as $r): ?>
        <li class="hc-rel-item">
          <a class="hc-rel-link" href="<?= hc_esc(hc_u('article=' . urlencode((string)($r['slug'] ?? '')))) ?>">
            <?= hc_esc((string)($r['page_title'] ?? '')) ?>
          </a>
        </li>
        <?php endforeach; ?>
      </ul>
    </div>
    <?php return (string)ob_get_clean();
}

function hc_render_subpage_hero(): string {
    /* $_settings MUST be imported by name — this function cannot see the outer scope,
     * and an option read from a missing global silently falls back to its default,
     * which is exactly how every band control appeared to do nothing. */
    global $__t, $_article, $_category, $_catSlug, $_articles, $showStats, $_layout, $_settings,
           $__usePortalHero, $__pd, $_query, $helpBase, $txtSearchPH;

    $isArticle  = is_array($_article);
    $isCategory = (!$isArticle && $_catSlug !== '' && is_array($_category));
    /* PHASE_HC_SEARCH_SUBHERO_2026-08-17 — the SEARCH surface may carry the band too.
     * `hero_search=subhero` is offered in the Studio and was set on this workspace, but
     * this function returned '' for anything that was not an article or a category, so
     * the choice rendered nothing. The search view now gets a band titled with the
     * query and sub-titled with the result count, the same chrome as a category. */
    $isSearch   = (!$isArticle && !$isCategory && trim((string)($_query ?? '')) !== '');
    if (!$isArticle && !$isCategory && !$isSearch) return '';

    $title = $isArticle
        ? (string)($_article['page_title'] ?? 'Article')
        : ($isCategory ? (string)($_category['name'] ?? 'Category')
        : sprintf($__t('seo_search_title', 'Search: %s'), (string)$_query));

    /* PHASE10K9_2026-08-11 — WHEN THE PORTAL HERO IS CHOSEN, ITS SUB-HERO COMES
     * WITH IT. The owner: "the selected hero system should remain visually
     * consistent from top to bottom." Pairing the Portal's main hero with the
     * Help Center's own page-header band is exactly the mix they ruled out, so
     * the Portal's band renders here, from the Portal's own function, with this
     * page's heading and subtitle passed in. The Portal's own band switch does NOT
     * gate this (owner rule 2026-09-16, below); only the Help Center's hero mode
     * (subhero vs search / none) decides whether a band renders at all. */
    if (!empty($__usePortalHero) && is_array($__pd ?? null)
        && function_exists('opsiq_portal_render_subhero')) {
        $__sub = '';
        if ($isCategory) {
            $__d = trim((string)($_category['description'] ?? ''));
            if ($__d !== '') $__sub = mb_strimwidth($__d, 0, 180, '…');
        }
        /* OWNER RULE (2026-09-16): "as long as you select use portal hero, sub should have the same,
         * unless /hc is not using the subhero design (search or none)". The portal's own "Page header
         * band" switch governs the PORTAL's pages only; it must not decide the Help Center's. It used
         * to: opsiqai.com had the portal band off, so its Help Center fell back to its own band while
         * support.nabtech.co (band on) matched. When the portal band is off, its style controls were
         * never curated, so it follows the portal hero ("Match my hero"). */
        $__pdBand = $__pd;
        $__pdBand['subhero'] = is_array($__pdBand['subhero'] ?? null) ? $__pdBand['subhero'] : [];
        if (empty($__pdBand['subhero']['enabled'])) {
            $__pdBand['subhero']['enabled'] = true;
            $__pdBand['subhero']['inherit'] = true;
        }
        $__portalBand = (string) opsiq_portal_render_subhero($__pdBand, $title, $__sub);
        if (trim($__portalBand) !== '') {
            /* THE TRAIL GOES IN THE BAND, the way the Portal puts it there.
             * portal.php folds the page's own breadcrumb into .psh-row > .psh-copy
             * as .psh-crumb; the band's markup carries no crumb of its own, which
             * is why it arrived here with none. Same classes, same nesting, built
             * from the Help Center's own trail so the links are this page's. */
            $__crumb = hc_subhero_crumb_html();
            /* THE BAND IS A MINI VERSION OF ITS HERO, SEARCH INCLUDED (owner, 2026-09-16). In the
             * portal, pxSubhero() hangs the page's own search box in .psh-search beside the title;
             * /hc runs no portal script, so the band arrived with no search at all. Built here on
             * the server, in the portal's .psh-search/.rq-search shape (styled by the same
             * extracted band CSS), wired to the Help Center's own search and suggestions. Follows
             * the Help Center's existing "search in page header band" switch, default on. */
            $__shSearchOn = !in_array(strtolower((string)($_settings['subhero_search'] ?? '1')), ['', '0', 'off', 'false', 'no'], true);
            $__searchSlot = '';
            /* THE HERO'S OWN SEARCH, at band size: the same component the hero renders (glyph,
             * action label, pill/field treatment per style), adapted to the Help Center's search
             * and suggestions with the same id swap the landing hero uses. Only the SEARCH component is
             * borrowed, never the hero renderer: the band keeps its one layout (see HcSubheroBandTest). The generic field below is
             * only the fallback for a hero whose design has no search. */
            /* The band's design: the hero's with "Match my hero" on (default), the chosen Band
             * design with it off. Its search is rendered AS that design, so the two never disagree. */
            $__bandStyle = function_exists('opsiq_portal_subhero_style') ? opsiq_portal_subhero_style($__pd) : 'classic';
            $__pdSearch = $__pd;
            $__pdSearch['blocks']['hero']['style'] = $__bandStyle;
            $__heroSearch = ($__shSearchOn && function_exists('opsiq_portal_render_search')) ? (string)opsiq_portal_render_search($__pdSearch) : '';
            if ($__heroSearch !== '') {
                $__heroSearch = str_replace(
                    ['id="px-hero-search"', 'id="px-hero-search-results" class="psearch-res"', 'aria-controls="px-hero-search-results"', 'type="text"'],
                    ['id="hc-hero-input" name="q" data-hc-search="hero"', 'id="hc-suggest-hero" class="psearch-res hc-suggest"', 'aria-controls="hc-suggest-hero"', 'type="search"'],
                    $__heroSearch
                );
                $__searchSlot = '<div class="psh-search psh-herosearch phero-unit phu-' . hc_esc($__bandStyle) . '">' . $__heroSearch . '</div>';
            } elseif ($__shSearchOn) {
                $__ph = (string)($txtSearchPH ?? '');   /* already resolved + translated: text_search_placeholder */
                $__searchSlot = '<div class="psh-search"><form class="rq-search hc-search-form" id="hc-hero-form" role="search" action="' . hc_esc((string)$helpBase) . '" method="get" autocomplete="off">'
                    . hc_hidden()
                    . '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>'
                    . '<input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="' . hc_esc($__ph) . '" aria-label="' . hc_esc($__ph) . '"'
                    . ' value="' . hc_esc((string)($_query ?? '')) . '" autocomplete="off" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">'
                    . '</form><div id="hc-suggest-hero" class="psearch-res hc-suggest" role="listbox" hidden></div></div>';
            }
            if ($__crumb !== '' || $__searchSlot !== '') {
                $__portalBand = preg_replace(
                    '~(<h1 class="phero-h">.*?</h1>(?:\s*<p class="phero-s">.*?</p>)?)~s',
                    '<div class="psh-row"><div class="psh-copy">$1' . str_replace('$', '\\$', $__crumb) . '</div>' . str_replace('$', '\\$', $__searchSlot) . '</div>',
                    $__portalBand,
                    1
                );
            }
            /* The band's <style> goes BEFORE the wrapper, never inside it: the portal pulls its
             * band up under the nav with `.psubhero:first-child`, and a style tag as the first
             * child made that rule miss, leaving a strip of page above the band. */
            return hc_portal_subhero_css() . '<div class="hc-subhero-portal" data-hc-subhero-source="portal">'
                 . $__portalBand . '</div>';
        }
    }

    $sub = '';
    if ($isCategory) {
        $desc = trim((string)($_category['description'] ?? ''));
        if ($desc !== '') $sub = mb_strimwidth($desc, 0, 180, '…');
        elseif ($showStats) {
            $n = is_array($_articles) ? count($_articles) : 0;
            $sub = $n . ' article' . ($n !== 1 ? 's' : '');
        }
    }
    if ($isSearch) {
        $n = is_array($_articles) ? count($_articles) : 0;
        $sub = $n . ' ' . $__t($n === 1 ? 'article_one' : 'article_many', $n === 1 ? 'article' : 'articles');
    }

    /* ⚠ KEEP THIS IN STEP WITH THE hcsk- SKINS. A layout listed here paints a LIGHT
     * field, so it takes dark ink and a solid white search pill; everything else is
     * a deep field taking white ink and a translucent one. Two places describing one
     * decision — the band test asserts they agree. */
    $lightSkins = ['aurora', 'minimal', 'classic', 'zenith', 'ledger', 'sanctuary'];
    $lay  = preg_replace('/[^a-z0-9_-]/', '', (string)$_layout) ?: 'nebula';
    $tone = in_array($lay, $lightSkins, true) ? 'hcsh-onlight' : 'hcsh-ondark';

    /* PHASE1_2026-08-06 — the band's own options. Every one of these keys already
     * existed in live settings blobs and was deliberately kept through the Phase 0
     * purge, so an operator who configured this band before the rollback gets their
     * choices back rather than a reset. Enums are validated at save, so the values
     * arriving here are known-good and need no defending against. */
    $shOpt   = static fn(string $k, $d) => $_settings[$k] ?? $d;
    $shBool  = static fn(string $k, bool $d) => !in_array(strtolower((string)($_settings[$k] ?? ($d ? '1' : ''))), ['', '0', 'off', 'false', 'no'], true);
    $shHeight = (string)$shOpt('subhero_height', 'standard');
    $shAlign  = (string)$shOpt('subhero_align', 'left');
    $shWidth  = (string)$shOpt('subhero_width', 'full');
    $shCrumbs = $shBool('subhero_crumbs', true);
    $shCrPos  = (string)$shOpt('subhero_crumbs_pos', 'below');
    $shDesc   = $shBool('subhero_desc', true);
    $shMeta   = $shBool('subhero_meta', true);
    $shIcon   = $shBool('subhero_icon', false);
    $shSearch = $shBool('subhero_search', true);
    $shEyebrow = trim((string)$shOpt('subhero_eyebrow', ''));
    /* ⚠ The `hc-subhero` CLASS is deliberately NOT emitted. The eighty old per-theme
     * rules are written `#hc-page.layout-x:is(.view-article,.view-category) .hc-subhero`
     * — an id plus three classes — so they beat any `.hcsk-` skin on specificity and
     * silently repaint the band. Carrying the class for one build gave aurora a
     * near-white field under white ink: invisible text, and no measurement caught it
     * because the geometry was perfect. Dropping the class is what makes those rules
     * inert. The ID `#hc-subhero` stays: the router, the tests and the harness key off it. */
    $cls  = 'hcsh hcsh-h-' . $shHeight . ' hcsh-a-' . $shAlign . ' hcsh-w-' . $shWidth
          . ' hcsk-' . $lay . ' ' . $tone;

    ob_start(); ?>
    <section id="hc-subhero" class="<?= hc_esc($cls) ?>" aria-label="<?= hc_esc($__t("page_header", "Page header")) ?>">
      <div class="hcsh-tex" aria-hidden="true"><i></i><i></i><i></i></div>
      <div class="hcsh-in">
        <div class="hcsh-row">
          <div class="hcsh-copy">
            <?php if ($shEyebrow !== ''): ?><div class="hcsh-eyebrow"><?= hc_esc($shEyebrow) ?></div><?php endif; ?>
            <div class="hcsh-head">
              <?php if ($shIcon && $_category && !empty($_category['icon'])): $__sh = hc_cat_icon_html((string)$_category['icon']); $__sm = (strpos($__sh, '<img') === 0 || strpos($__sh, '<svg') === 0); ?>
                <span class="hcsh-icon<?= $__sm ? ' hc-ico-has-img' : '' ?>" aria-hidden="true"><?= $__sh ?></span>
              <?php endif; ?>
              <h1 class="hcsh-title"><?= hc_esc($title) ?></h1>
            </div>
            <?php if ($shDesc && $sub !== ''): ?><p class="hcsh-sub"><?= hc_esc($sub) ?></p><?php endif; ?>
            <?php if ($shCrumbs): ?>
            <?php /* Default is UNDER the title, per the owner: "the breadcrumbs bring under
                    not above" — but an operator can put it back on top. */ ?>
            <nav class="hc-crumb hcsh-crumb hcsh-crumb-<?= hc_esc($shCrPos) ?>" aria-label="<?= hc_esc($__t("breadcrumb", "Breadcrumb")) ?>">
              <a href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t('home', 'Help Center')) ?></a>
              <?php foreach (hc_cat_ancestors($_category) as $__anc): ?>
                <span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>
                <a href="<?= hc_esc(hc_u('cat=' . urlencode((string)($__anc['slug'] ?? '')))) ?>"><?= hc_esc((string)($__anc['name'] ?? '')) ?></a>
              <?php endforeach; ?>
              <?php if ($_category): ?>
                <span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>
                <?php if ($isArticle): ?><a href="<?= hc_esc(hc_u('cat=' . urlencode((string)($_category['slug'] ?? '')))) ?>"><?= hc_esc((string)($_category['name'] ?? 'Category')) ?></a><?php else: ?><span><?= hc_esc((string)($_category['name'] ?? 'Category')) ?></span><?php endif; ?>
              <?php endif; ?>
              <?php if ($isArticle): ?>
                <span class="hc-crumb-sep" aria-hidden="true">&rsaquo;</span>
                <span><?= hc_esc(mb_strimwidth($title, 0, 78, '…')) ?></span>
              <?php endif; ?>
            </nav>
            <?php endif; ?>
          </div>
          <?php /* THE SAME search the full hero uses — same markup, same ids, same wired
                  behaviour, so autocomplete and the suggest panel are identical. autofocus
                  is stripped: on an inner page it would yank the viewport to the field and
                  open the mobile keyboard over the content the reader came for. */ ?>
          <?php if ($shSearch): ?>
          <div class="hcsh-search"><?= str_replace(' autofocus', '', hc_search_markup($lay)) ?></div>
          <?php endif; ?>
        </div>
      </div>
    </section>
    <?php return (string)ob_get_clean();
}

/* PHASE3_2026-08-06 — hc_cat_card() was DELETED here.
 *
 * It was the old subcategory renderer and had been unreachable for some time:
 * the only three mentions left in this file were comments explaining that child
 * categories had been moved onto hc_cat_tiles() so they inherit the subcategory
 * style and icon choice. The plan required it be revived as a Phase 3 variant or
 * removed; the directory presentations now cover what it did, including its
 * subcategory badges, which land as .hc-dir-kids chips.
 *
 * Its CSS classes were NOT removed with it: hc-cat, hc-cat-ico, hc-cat-name and
 * the rest are shared with live renderers (hc-cat alone appears ~590 more times).
 * Only .hc-cat-copy was unique to it, and no rule ever targeted that class, so
 * there was nothing to delete. */

/* PHASE_KB_IMPORT (K3) — ordered ancestor categories (root first) of a category,
 * for nested breadcrumbs. Cycle-guarded. Empty for top-level categories. */
function hc_cat_ancestors($cat): array {
    global $_categories;
    if (!is_array($cat) || empty($_categories) || !is_array($_categories)) return [];
    $byId = [];
    foreach ($_categories as $c) { $byId[(int)($c['id'] ?? 0)] = $c; }
    $chain = []; $pid = (int)($cat['parent_id'] ?? 0); $guard = 0; $seen = [];
    while ($pid > 0 && isset($byId[$pid]) && $guard++ < 20 && empty($seen[$pid])) {
        $seen[$pid] = true;
        array_unshift($chain, $byId[$pid]);
        $pid = (int)($byId[$pid]['parent_id'] ?? 0);
    }
    return $chain;
}

function hc_category_rail(array $cats): string {
    global $showStats, $_catSlug, $txtBrowseLabel, $categorySidebarStyle, $categorySidebarScroll, $__t;

    $all = array_values(array_filter($cats, function($c){ return (int)($c['id'] ?? 0) > 0; }));
    if (!$all) return '';

    $byId = [];
    foreach ($all as $cat) {
        $id = (int)($cat['id'] ?? 0);
        if ($id > 0) $byId[$id] = $cat;
    }

    $children = [];
    foreach ($all as $cat) {
        $id = (int)($cat['id'] ?? 0);
        if ($id <= 0) continue;
        $pid = (int)($cat['parent_id'] ?? 0);
        if ($pid > 0 && !isset($byId[$pid])) $pid = 0;
        if (!isset($children[$pid])) $children[$pid] = [];
        $children[$pid][] = $cat;
    }

    $visibleMemo = [];
    $countMemo = [];

    $hasVisible = function(int $id) use (&$hasVisible, &$visibleMemo, $byId, $children): bool {
        if (isset($visibleMemo[$id])) return $visibleMemo[$id];
        $cat = $byId[$id] ?? null;
        if (!$cat) return $visibleMemo[$id] = false;
        if ((int)($cat['article_count'] ?? 0) > 0) return $visibleMemo[$id] = true;
        foreach ($children[$id] ?? [] as $child) {
            $cid = (int)($child['id'] ?? 0);
            if ($cid > 0 && $hasVisible($cid)) return $visibleMemo[$id] = true;
        }
        return $visibleMemo[$id] = false;
    };

    $visibleCount = function(int $id) use (&$visibleCount, &$countMemo, $byId, $children): int {
        if (isset($countMemo[$id])) return $countMemo[$id];
        $cat = $byId[$id] ?? null;
        if (!$cat) return $countMemo[$id] = 0;
        $sum = (int)($cat['article_count'] ?? 0);
        foreach ($children[$id] ?? [] as $child) {
            $cid = (int)($child['id'] ?? 0);
            if ($cid > 0) $sum += $visibleCount($cid);
        }
        return $countMemo[$id] = $sum;
    };

    $activeId = 0;
    if ($_catSlug !== '') {
        foreach ($all as $cat) {
            if ((string)($cat['slug'] ?? '') === $_catSlug) {
                $activeId = (int)($cat['id'] ?? 0);
                break;
            }
        }
    }
    $openPath = [];
    if ($activeId > 0) {
        $cursor = $activeId;
        while ($cursor > 0 && isset($byId[$cursor])) {
            $openPath[$cursor] = true;
            $cursor = (int)($byId[$cursor]['parent_id'] ?? 0);
            if (isset($openPath[$cursor])) break;
        }
    }

    $renderLink = function(array $cat, int $depth = 0) use ($_catSlug, $showStats, $visibleCount , $__t): string {
        $icon = !empty($cat['icon']) ? (string)$cat['icon'] : '📁';
        $name = (string)($cat['name'] ?? 'Category');
        $desc = mb_strimwidth((string)($cat['description'] ?? ''), 0, 84, '…');
        $slug = (string)($cat['slug'] ?? '');
        $count = (int)$visibleCount((int)($cat['id'] ?? 0));
        $url = hc_u('cat=' . urlencode($slug));
        $active = ($_catSlug !== '' && $slug === $_catSlug) ? ' is-active' : '';
        $pad = max(0, min(5, $depth)) * 12;
        ob_start(); ?>
          <a class="hc-kb-cat-link<?= $active ?>" href="<?= hc_esc($url) ?>" style="padding-left:<?= 12 + $pad ?>px">
            <?php $__ih = hc_cat_icon_html($icon); if ($__ih === '') $__ih = hc_esc('📁'); $__im = (strpos($__ih,'<img')===0||strpos($__ih,'<svg')===0); ?>
            <span class="hc-kb-cat-icon<?= $__im ? ' hc-ico-has-img' : '' ?>" aria-hidden="true"><?= $__ih ?></span>
            <span class="hc-kb-cat-copy">
              <span class="hc-kb-cat-name"><?= hc_esc($name) ?></span>
              <?php if ($desc !== ''): ?><span class="hc-kb-cat-desc"><?= hc_esc($desc) ?></span><?php endif; ?>
              <?php if ($showStats): ?><span class="hc-kb-cat-count"><?= $count ?> <?= hc_esc($__t($count === 1 ? "article_one" : "article_many", $count === 1 ? "article" : "articles")) ?></span><?php endif; ?>
            </span>
            <span class="hc-kb-cat-arrow" aria-hidden="true">→</span>
          </a>
        <?php return (string)ob_get_clean();
    };

    $renderBranch = function(int $parentId = 0, int $depth = 0) use (&$renderBranch, $children, $hasVisible, $renderLink, $openPath): string {
        $html = '';
        foreach ($children[$parentId] ?? [] as $cat) {
            $id = (int)($cat['id'] ?? 0);
            if ($id <= 0 || !$hasVisible($id)) continue;
            $childHtml = $renderBranch($id, $depth + 1);
            if ($childHtml !== '') {
                $open = isset($openPath[$id]);
                $html .= '<details class="hc-kb-group level-' . (int)$depth . '"' . ($open ? ' open' : '') . '>';
                $html .= '<summary class="hc-kb-group-summary">' . $renderLink($cat, $depth) . '</summary>';
                $html .= '<div class="hc-kb-children">' . $childHtml . '</div>';
                $html .= '</details>';
            } else {
                $html .= $renderLink($cat, $depth);
            }
        }
        return $html;
    };

    $rootEntries = [];
    foreach ($children[0] ?? [] as $rootCat) {
        $rid = (int)($rootCat['id'] ?? 0);
        if ($rid <= 0 || !$hasVisible($rid)) continue;
        $branchHtml = $renderBranch($rid, 1);
        $entry = '<div class="hc-kb-entry">';
        if ($branchHtml !== '') {
            $open = isset($openPath[$rid]);
            $entry .= '<details class="hc-kb-group level-0"' . ($open ? ' open' : '') . '>';
            $entry .= '<summary class="hc-kb-group-summary">' . $renderLink($rootCat, 0) . '</summary>';
            $entry .= '<div class="hc-kb-children">' . $branchHtml . '</div>';
            $entry .= '</details>';
        } else {
            $entry .= $renderLink($rootCat, 0);
        }
        $entry .= '</div>';
        $rootEntries[] = $entry;
    }

    if (!$rootEntries) return '';

    $totalCats = count($rootEntries);
    $isExpandedDefault = ($totalCats <= 10);

    ob_start(); ?>
    <aside class="hc-kb-sidebar is-collapsible hc-kb-style-<?= hc_esc($categorySidebarStyle) ?> hc-kb-sb-<?= hc_esc($categorySidebarScroll ?? 'thin') ?>" aria-label="<?= hc_esc($txtBrowseLabel) ?>">
      <?php /* PHASE10K9_2026-08-11 — THE SIDEBAR HEAD IS ITS OWN COMPONENT.
               It used to be the HOME section header (.hc-sec-label + .hc-sec-line),
               which is why a home divider line ran across every sidebar and why the
               heading picked up home label styling instead of the sidebar's own.
               It now carries .hc-side-title, the SAME class the "On this page" and
               "Related articles" cards use, so every sidebar label shares one type
               style and one size setting. .hc-label-browse stays for the wording
               size control that already targets it. */ ?>
      <div class="hc-kb-sidebar-head"<?= $categorySidebarStyle === "drawer" ? " role=\"button\" tabindex=\"0\" data-hc-drawer-toggle aria-expanded=\"false\"" : "" ?>>
        <span class="hc-side-title hc-side-title-nav hc-label-browse"><?= hc_esc($txtBrowseLabel) ?></span>
      </div>
      <nav class="hc-kb-nav<?= $isExpandedDefault ? '' : ' is-trimmed' ?>" aria-label="<?= hc_esc($__t("knowledge_base_categories", "Knowledge base categories")) ?>">
        <?= implode('', $rootEntries) ?>
      </nav>
      <?php if ($totalCats > 10): ?>
      <button
        type="button"
        class="hc-kb-more"
        data-kb-toggle
        data-total="<?= (int)$totalCats ?>"
        data-more-label="<?= hc_esc($__t("show_all_cats", "Show all categories")) ?>"
        data-less-label="<?= hc_esc($__t("show_fewer", "Show fewer categories")) ?>"
        aria-expanded="<?= $isExpandedDefault ? 'true' : 'false' ?>"
      ><?= hc_esc($isExpandedDefault ? $__t("show_fewer", "Show fewer categories") : ($__t("show_all_cats", "Show all categories") . " (" . (int)$totalCats . ")")) ?></button>
      <?php endif; ?>
    </aside>
    <?php return (string)ob_get_clean();
}

/* PHASE_HC_HOME_LAYOUT — in categories-home mode, the side-nav on a category /
 * article page lists the ARTICLES of the current category (directory pattern),
 * not every category. Reuses the .hc-kb-sidebar shell so all sidebar styles +
 * left/right position apply unchanged. */
function hc_category_articles_rail(int $categoryId, string $categoryName): string {
    global $categorySidebarStyle, $categorySidebarScroll, $_articleSlug, $_siteKey, $_i18nOn, $_locale, $_i18nSource, $__t;
    if ($categoryId <= 0) return '';
    $arts = HelpCenter::listPublicArticles($_siteKey, $categoryId, 200, 0);
    if (!$arts) return '';
    /* PHASE_HC_I18N_AI — the sidebar builds its OWN sibling-article list inside this
     * render fn, so it needs its own overlay (the earlier list overlays never see it). */
    if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
        try { \OpsIQ\Kb\HcTranslator::overlayArticles($arts, $_siteKey, $_locale); } catch (\Throwable $e) {}
    }
    ob_start(); ?>
    <aside class="hc-kb-sidebar is-collapsible hc-kb-style-<?= hc_esc($categorySidebarStyle) ?> hc-kb-sb-<?= hc_esc($categorySidebarScroll ?? 'thin') ?> hc-kb-articles" aria-label="<?= hc_esc(sprintf($__t("articles_in_named_category", "Articles in %s"), $categoryName)) ?>">
      <div class="hc-kb-sidebar-head"<?= $categorySidebarStyle === "drawer" ? " role=\"button\" tabindex=\"0\" data-hc-drawer-toggle aria-expanded=\"false\"" : "" ?>>
        <span class="hc-side-title hc-side-title-nav hc-label-browse"><?= hc_esc($categoryName !== "" ? $categoryName : $__t("stat_articles", "Articles")) ?></span>
      </div>
      <nav class="hc-kb-nav" aria-label="<?= hc_esc($__t("articles_in", "Articles in this category")) ?>">
        <?php foreach ($arts as $a):
          $aslug  = (string)($a['slug'] ?? '');
          $active = ($_articleSlug !== '' && $aslug === $_articleSlug) ? ' is-active' : '';
        ?>
        <a class="hc-kb-cat-link hc-kb-art-link<?= $active ?>" href="<?= hc_esc(hc_u('article=' . urlencode($aslug))) ?>">
          <span class="hc-kb-cat-icon" aria-hidden="true">📄</span>
          <span class="hc-kb-cat-copy"><span class="hc-kb-cat-name"><?= hc_esc((string)($a['page_title'] ?? '')) ?></span></span>
          <span class="hc-kb-cat-arrow" aria-hidden="true">→</span>
        </a>
        <?php endforeach; ?>
      </nav>
    </aside>
    <?php return (string)ob_get_clean();
}
/* PHASE10J_2026-08-11 — ONE row builder for the article LISTINGS (category page
 * and search results), because the two inline copies could only drift — and had:
 * the search rows lost their views meta somewhere along the way.
 *
 * Twelve presentations, each declaring its PARTS here rather than hiding them
 * with CSS (the directory renderer's rule: markup not emitted is not in the
 * accessibility tree being read out, and costs no bytes):
 *
 *   boxed / media       the two originals, untouched
 *   list                inset hairline rows, chevron slide
 *   timeline            date-chip rail with connector nodes
 *   compact             dense numbered index, no excerpt
 *   feature             first article promoted to a lead story, rest a grid
 *   split               alternating letterform panels
 *   showcase            typographic, oversized titles with date kickers
 *   glass               frosted card deck
 *   ledger              spec-sheet rows with tabular meta
 *   rail                horizontal snap-scroll shelf
 *   mosaic              bento grid with spanning tiles
 */
function hc_arow_item(array $art, int $i): string {
    global $articleCardStyle, $__t;
    $s      = $articleCardStyle ?: 'list';
    $img    = trim((string)($art['image_url'] ?? ''));
    $url    = hc_u('article=' . urlencode((string)($art['slug'] ?? '')));
    $title  = (string)($art['page_title'] ?? '');
    /* PHASE_PORTAL_P13 audit — a customers-only article must not quote its body
     * opening on a listing card: the excerpt IS the body (720 chars of it), and
     * the meta/widget/portal paths already mask this exact string. Title stays —
     * discovery is the point. */
    $exc    = \OpsIQ\Kb\HelpCenter::canReadArticle($art)
        ? hc_clean_excerpt($title, $art['excerpt'] ?? '')
        : (string)$__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.');
    $views  = (int)($art['views_count'] ?? 0);
    $ts     = strtotime((string)($art['created_at'] ?? '')) ?: 0;

    $wantNum    = in_array($s, ['compact', 'ledger', 'rail', 'showcase', 'mosaic'], true);
    $wantDate   = in_array($s, ['timeline', 'chronicle'], true);
    $wantKicker = ($s === 'showcase' && $ts > 0);
    $wantLetter = in_array($s, ['split', 'stub'], true);
    $isLead     = ($s === 'feature' && $i === 0);
    /* PHASE_HC_LISTINGS_2026-08-14 — `rail` is a FULL-WIDTH billboard now, not a thin
     * row, and a card that tall with only a title in it is mostly empty space. The
     * two genuinely dense presentations keep their no-excerpt rule. */
    $wantExc    = !in_array($s, ['compact', 'ledger'], true) && $exc !== '';

    ob_start(); ?>
    <a class="hc-arow<?= $isLead ? ' hc-arow-lead' : '' ?>" role="listitem" href="<?= hc_esc($url) ?>">
      <?php if ($s === 'media'): ?>
        <span class="hc-arow-media" aria-hidden="true"<?= $img !== '' ? ' style="background-image:url(\'' . hc_esc(hc_asset_url($img)) . '\')"' : '' ?>><?php if ($img === ''): ?><span class="hc-arow-media-ph">📄</span><?php endif; ?></span>
      <?php elseif ($wantLetter): ?>
        <span class="hc-arow-letter" aria-hidden="true"><?= hc_esc(function_exists('mb_strtoupper') ? mb_strtoupper(mb_substr(trim($title) !== '' ? $title : '?', 0, 1)) : strtoupper(substr($title !== '' ? $title : '?', 0, 1))) ?></span>
      <?php elseif ($wantDate): ?>
        <span class="hc-arow-date" aria-hidden="true"><b><?= $ts ? (int)date('j', $ts) : '·' ?></b><i><?= $ts ? hc_esc(date('m.Y', $ts)) : '' ?></i></span>
        <span class="hc-arow-node" aria-hidden="true"></span>
      <?php elseif (in_array($s, ['list', 'boxed'], true)): ?>
        <span class="hc-arow-dot" aria-hidden="true"></span>
      <?php endif; ?>
      <?php if ($wantNum): ?><span class="hc-arow-num" aria-hidden="true"><?= str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT) ?></span><?php endif; ?>
      <div class="hc-arow-body">
        <?php if ($isLead): ?><span class="hc-arow-kicker"><?= hc_esc($__t('featured_kicker', 'Featured')) ?></span><?php endif; ?>
        <?php if ($wantKicker): ?><span class="hc-arow-kicker"><?= hc_esc(date('m.Y', $ts)) ?></span><?php endif; ?>
        <div class="hc-arow-title"><?= hc_esc($title) ?></div>
        <?php if ($wantExc): ?><div class="hc-arow-exc"><?= hc_esc($exc) ?></div><?php endif; ?>
      </div>
      <div class="hc-arow-meta">
        <?php if ($views > 0): ?>
        <span class="hc-views">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
          <?= number_format($views) ?>
        </span>
        <?php endif; ?>
        <svg class="hc-arr" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
      </div>
    </a>
    <?php return (string)ob_get_clean();
}

function hc_pop_card(array $art, int $i, string $variant): string {
    /* PHASE_HC_CHROME_I18N — $__t translates the non-operator chrome ("views").
     * Same import trap as every other global used inside a function here. */
    global $articleCardStyle, $__t;
    $url = hc_u('article=' . urlencode((string)($art['slug'] ?? '')));
    $cardStyle = $articleCardStyle ?: 'list';
    $img = trim((string)($art['image_url'] ?? ''));
    /* PHASE_HC_MORE_CONFIG — the "media" layout leads with the article image
     * (or a branded gradient placeholder when none is set) above title + body. */
    ob_start(); ?>
    <a class="hc-pop-item hc-pop-<?= hc_esc($variant) ?> hc-card-<?= hc_esc($cardStyle) ?>" role="listitem" href="<?= hc_esc($url) ?>">
      <?php if ($cardStyle === 'media'): ?>
        <span class="hc-pop-media" aria-hidden="true"<?= $img !== '' ? ' style="background-image:url(\''.hc_esc(hc_asset_url($img)).'\')"' : '' ?>><?php if ($img === ''): ?><span class="hc-pop-media-ph">📄</span><?php endif; ?></span>
      <?php else: ?>
        <span class="hc-pop-num" aria-hidden="true"><?= $i + 1 ?></span>
      <?php endif; ?>
      <div class="hc-pop-body"><div class="hc-pop-title"><?= hc_esc((string)($art['page_title'] ?? '')) ?></div><?php $__exc = \OpsIQ\Kb\HelpCenter::canReadArticle($art) ? hc_clean_excerpt($art['page_title'] ?? '', $art['excerpt'] ?? '') : (string)$__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.'); if ($__exc !== ''): ?><div class="hc-pop-exc"><?= hc_esc($__exc) ?></div><?php endif; ?>
        <?php if ($cardStyle !== 'list' && (int)($art['views_count'] ?? 0) > 0): ?><span class="hc-pop-views hc-pop-views-inline"><?= hc_esc(str_replace('{n}', number_format((int)$art['views_count']), $__t('views_count', '{n} views'))) ?></span><?php endif; ?>
      </div>
      <?php if ($cardStyle === 'list' && (int)($art['views_count'] ?? 0) > 0): ?><span class="hc-pop-views"><?= hc_esc(str_replace('{n}', number_format((int)$art['views_count']), $__t('views_count', '{n} views'))) ?></span><?php endif; ?>
      <?php if ($cardStyle === 'list'): ?><span class="hc-arr" aria-hidden="true">→</span><?php endif; ?>
    </a>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE_HC_SUBCAT_STYLE — THE category-tile grid. One renderer, two callers:
 * the home page (home_category_style / home_category_icons / home_category_limit)
 * and the child categories on a category page (subcategory_style /
 * subcategory_icons). Before this existed the children went through
 * hc_cat_card(), a different component, which is why they looked undesigned.
 *
 * $style  — card | badge | minimal | glow | list | bare | plain
 * $icons  — false renders the tiles with no icon at all (.hc-home-cats-noicon)
 * $extra  — extra class on the grid (e.g. hc-subcats-grid for the tighter gap)
 * $hideFrom — index from which tiles are collapsed behind "Show all" (0 = none).
 *   The tiles are still RENDERED (only hidden with CSS), so every category stays
 *   a real, crawlable link even while the home page shows a short curated set.
 */
/**
 * PHASE3_2026-08-06 — DIRECTORY CARDS, the second home category presentation.
 *
 * A genuinely different STRUCTURE, not a restyle of the tiles: each category is
 * a card with its own header, a short list of its actual articles, and a link to
 * the rest. A tile says "Hosting, 14 articles"; a directory card shows the reader
 * four of those fourteen by name, which is the whole point of the presentation.
 *
 * WHERE THE ARTICLES COME FROM.
 * The plan recorded that per-category articles were "already fetched and thrown
 * away". Half true, and the half that is wrong matters: $_categories carries
 * article_count but never an `articles` key — the translator's
 * `if (!empty($__cwa['articles']))` guards a key that never exists. What IS
 * fetched is $_allArticles, a flat list of up to 200 public articles that
 * already carries category_id through hydrateArticle().
 *
 * So this groups that existing list rather than issuing a query per category.
 * With 44 categories the query-per-card approach would have been 44 extra round
 * trips on the busiest page of the site, to display data already in memory.
 *
 * The consequence, stated plainly because it is a real limit: a category whose
 * articles all fall outside the 200 most-viewed shows its header and its
 * "see all" link with no preview rows. That is correct behaviour rather than a
 * bug — the card still routes the reader — but it is why the preview count is
 * capped low by default.
 */
/**
 * PHASE0.5_2026-08-09 — $hideFrom: the category rule's limit, applied HERE too.
 *
 * `hc_cat_tiles()` has always received the limit and marked everything past it with a
 * class the CSS hides, so "Show all categories" reveals the rest without a round-trip and
 * every category stays a real, crawlable link. The eight non-tile layouts were never
 * given the limit at all: `home_categories` said limit 6 and the directory rendered all
 * 97 cards. Worse, the "Show all" control still rendered underneath them, because the
 * cap was computed — a control that revealed nothing, since nothing had been hidden.
 *
 * Same mechanism as the tiles rather than a slice, for the same reasons: the links stay
 * in the document for crawlers, and expanding is instant.
 */
/* PHASE10K30_2026-08-13 — THE RENDERER IS KEY-PREFIXED.
 * Owner: "sub categories need to have all the options categories have."
 *
 * Subcategories went straight to hc_cat_tiles() with two settings — a tile style
 * and an icon toggle — so not one of the twenty-two presentations, none of the
 * directory controls and none of the per-presentation options could ever reach
 * them. The fix is NOT a second copy of this renderer: it reads its settings
 * through a prefix, so 'cat_' drives the home page and 'subcat_' drives the
 * children, and every option either surface gains is gained by both. */
function hc_render_directory(array $cats, array $articles, int $cols, int $perCard, bool $collapsible, string $variant = 'directory', array $childrenOf = [], int $hideFrom = 0, array $articleRule = [], int $block = 1, string $kp = 'cat_'): string {
    global $__t, $showStats;

    if (!$cats) return '';

    /* Group once, not per card. */
    $byCat = [];
    foreach ($articles as $a) {
        $cid = (int)($a['category_id'] ?? 0);
        if ($cid <= 0) continue;
        $byCat[$cid][] = $a;
    }

    /* PHASE0.5_2026-08-09 — THE ARTICLE RULE DECIDES WHICH ARTICLES AND IN WHAT ORDER.
     * These previews used to be a raw slice of whatever order the fetch happened to
     * return, so "sort by most read", "only featured" or a tag filter on the article
     * section changed every article list on the site EXCEPT the ones inside a category
     * card. The rule is applied per card with its own cap removed, because how MANY
     * previews a card shows is cat_directory_links — the override control — and not the
     * section's list length. */
    if ($articleRule && class_exists('\OpsIQ\Kb\HcSections')) {
        $__ar = array_merge($articleRule, ['limit' => 0]);
        foreach ($byCat as $__cid => $__rows) {
            $byCat[$__cid] = \OpsIQ\Kb\HcSections::apply($__rows, $__ar, 'articles')['items'];
        }
    }

    $cols     = max(1, min(3, $cols));
    $perCard  = max(1, min(8, $perCard));

    /* PHASE10C_2026-08-11 — PER-CATEGORY article limits + the overflow link.
     * cat_article_limits is {"<category id>": n}: n = 0 means every article, a
     * missing id inherits cat_directory_links exactly as before, so the
     * inheritance chain reads category -> section rule -> global. The overflow
     * link gains an operator label (str_replace, not sprintf — an operator's %s
     * must not crash the page) and a presentation, including 'hide'. */
    $__catLimits = [];
    $__lraw = trim((string)($GLOBALS['_settings'][$kp . 'article_limits'] ?? ''));
    if ($__lraw !== '') {
        $__ld = json_decode($__lraw, true);
        if (is_array($__ld)) {
            foreach ($__ld as $__lk => $__lv) {
                $__lk = (int)$__lk; $__lv = (int)$__lv;
                if ($__lk > 0 && $__lv >= 0) $__catLimits[$__lk] = min(30, $__lv);
            }
        }
    }
    $__moreVariant = strtolower(trim((string)($GLOBALS['_settings'][$kp . 'more_variant'] ?? '')));
    if (!in_array($__moreVariant, ['', 'pill', 'quiet', 'hide'], true)) $__moreVariant = '';
    $__moreLabel = trim((string)($GLOBALS['_settings'][$kp . 'more_label'] ?? ''));

    /* PHASE3_2026-08-06 — the three structural variants share this renderer because
     * they share the DATA SHAPE (a category plus a few of its articles). What differs
     * is arrangement, which is CSS, plus two behavioural facts that are not:
     *
     *   accordion — one full-width column, every section CLOSED on arrival. That is a
     *               different reading contract, not a different skin: the reader scans
     *               headings first and opens what they want.
     *   compact   — a dense index for large knowledge bases. Never collapsible; a
     *               disclosure control on a row this small is a bigger target than the
     *               content it hides.
     *   split     — the FIRST category is promoted to a full-width card with more
     *               articles; the rest follow in the normal grid.
     */
    /* PHASE10K17_2026-08-12 — THE FIVE WEAK PRESENTATIONS ARE RETIRED, NOT DELETED.
     *
     * Owner: "compact index, accordion sections, editorial index, weighted names,
     * editorial lines … are not strong enough and need to be removed or
     * substantially redesigned. Do not simply rename these layouts."
     *
     * They are gone from every picker, and each one now RESOLVES to the new
     * architecture that replaces it. A workspace that saved one keeps rendering —
     * it renders the better thing — rather than silently falling back to the
     * default and losing the operator's choice. */
    $__retired = ['compact' => 'cmddir', 'accordion' => 'blueprint', 'editorial' => 'spine',
                  'cloud' => 'kgrid', 'marquee' => 'archdir'];
    if (isset($__retired[$variant])) $variant = $__retired[$variant];
    $variant = in_array($variant, ['directory', 'split', 'ribbon', 'panel', 'journey', 'bento', 'console', 'rail', 'masonry', 'index', 'toc', 'tree', 'onboard', 'archdir', 'blueprint', 'kgrid', 'campus', 'matrix', 'spine', 'krail', 'cmddir'], true) ? $variant : 'directory';
    /* PHASE10K17_2026-08-12 — the new architectures declare their own shape.
     * BLUEPRINT branches a category into its articles and keeps the disclosure the
     * retired accordion had; the rest are full-width compositions that own the row. */
    if ($variant === 'blueprint') { $collapsible = true; }
    if ($variant === 'bento')     { $cols = 4; }
    /* The four card-less layouts own their own flow entirely — a column count would
     * only put them back into the grid they exist to escape. */
    if (in_array($variant, ['console', 'index', 'toc', 'tree',
                            'archdir', 'campus', 'matrix', 'spine', 'krail', 'cmddir'], true)) { $cols = 1; }
    /* PHASE3B_2026-08-06 — four presentations built to the house design language:
     * a gradient-edged card, one big styled wrapper, a numbered journey rail and a
     * typographic index with no card at all.
     *
     * They differ in WHICH PARTS of a category they show, not only in arrangement,
     * so the extras are decided here rather than hidden with CSS. Rendering markup
     * and then hiding it wastes bytes on every page load and leaves it in the
     * accessibility tree, where a screen reader still reads it out. */
    /* PHASE9_2026-08-09 — the six new architectures declare their own parts. bento and
     * poster are icon-led by design; console is a palette row, where an icon is the
     * only thing that makes a dense line scannable. index shows neither: it is a
     * typographic A–Z and an icon column would fight the letter markers. */
    $showIcon = in_array($variant, ['ribbon', 'panel', 'bento', 'console', 'rail', 'onboard',
                                    'blueprint', 'kgrid', 'krail', 'spine', 'matrix'], true);
    $showDesc = in_array($variant, ['ribbon', 'panel', 'bento', 'rail', 'onboard',
                                    'archdir', 'blueprint', 'kgrid', 'spine', 'tree', 'krail'], true);
    $showNum  = in_array($variant, ['journey',
                                    'archdir', 'kgrid', 'campus', 'spine'], true);
    /* a journey is a sequence and an index is a list — neither collapses */
    /* A sequence, an index, a scrolling rail and a masonry flow all break if a card can
     * change height under the reader: the rail would resize mid-scroll and masonry would
     * re-flow every column. poster has no body to disclose at all. */
    if (in_array($variant, ['journey', 'rail', 'masonry', 'index', 'toc', 'tree', 'onboard',
                            'archdir', 'kgrid', 'campus', 'matrix', 'spine', 'krail', 'cmddir'], true)) $collapsible = false;
    /* PHASE9d_2026-08-10 — WHAT GOES INSIDE A CARD.
     * Owner: "it can also be used to show categories, and sub categories listed inside".
     * The data was always fetched — $childrenOf carries each category's children and they
     * rendered as a chip row — but there was no way to say "list those INSTEAD of the
     * articles", so a nested knowledge base could not present its hierarchy on the home
     * page. Default 'both' is exactly what every layout renders today. */
    $__cardContent = strtolower(trim((string)($GLOBALS['_settings'][$kp . 'card_content'] ?? 'both')));
    if (!in_array($__cardContent, ['articles', 'subcategories', 'both'], true)) $__cardContent = 'both';
    $showArts = $__cardContent !== 'subcategories';
    /* PHASE10K24_2026-08-12 — THE STACK SHOWS NO ARTICLES AT ALL.
     * Owner: "everything doesn't have to show articles. I said something unique,
     * different from what we have." Every other presentation in the set answers
     * "what is inside this category" by listing four titles. This one answers
     * "what IS this category" — the icon, the name, how much is behind it, the
     * ways in — and sends the reader to the category page for the rest.
     * Suppressed HERE and not in CSS: hidden article links still ship down the
     * wire and a screen reader still reads them out of a display:none list. */
    if ($variant === 'tree') $showArts = false;
    /* PHASE10K26_2026-08-12 — THE INDEX shows a description, not article titles.
     * Owner supplied the reference: a multi-column run of links, each with two
     * quiet lines under it. Those two lines are the category's own description and
     * its size — article titles under every entry would turn a scannable index
     * back into the directory it exists to be an alternative to. */
    if ($variant === 'krail') $showArts = false;
    /* PHASE9d_2026-08-10 — a description, an Open button and a chevron ON EACH ROW.
     * Only this variant: every other layout lists titles, and rendering an unused
     * paragraph per row in eighteen of nineteen presentations would cost bytes on every
     * page load and leave it in the accessibility tree for a screen reader to announce. */
    $showRowMeta = ($variant === 'onboard');
    $showKids = $__cardContent !== 'articles';

    /* PHASE_HC_HIERARCHY_2026-08-15 — THE HIERARCHY CONTROL NOW DECIDES SOMETHING.
     *
     * `<prefix>_hierarchy` offers three values and, until this, produced ONE result. Its
     * only consumer was a clause at the call site — `$hier === 'nested' || card_content in
     * [subcategories, both]` — whose right-hand side is true by default, so `$hier` was
     * never reached; and in the one case where it WOULD be reached (`card_content =
     * articles`), `$showKids` below had already been set false and zeroed the children
     * anyway. Six live renders of the three values, under both card_content settings, were
     * byte-identical: same md5, same 1751 bytes.
     *
     * Each label now means what it says:
     *   flat    every subcategory gets a card (the row set is built recursively at the
     *           call site), and children inside a card follow card_content as before
     *   top     top level only — a card per direct child, and NO children inside them
     *   nested  their own children inside — chips within each card, whatever
     *           card_content says, because that is the entire point of choosing it
     */
    $__hier = strtolower(trim((string)($GLOBALS['_settings'][$kp . 'hierarchy'] ?? 'flat')));
    if ($__hier === 'nested') $showKids = true;
    if ($__hier === 'top')    $showKids = false;

    /* …and no subcategories either. Owner: "only categories, it shouldn't show
     * sub in it." A run of child links under some entries and not others makes
     * the columns ragged and turns a flat index into a half-shown tree; the
     * category page is where the children belong. */
    if ($variant === 'krail') $showKids = false;

    $startClosed = false;
    /* the promoted card earns more previews; it has the room */
    $featureExtra = ($variant === 'split') ? 3 : 0;

    ob_start(); ?>
    <?php /* PHASE10K19_2026-08-12 — THE SCROLLING DECK IS A SLIDER.
             Owner: "it should work like a polished slider/carousel rather than a
             horizontally scrolling container… there must be no ugly native
             scrollbar." The deck keeps its scroll container — that is what gives
             it free momentum, snap, touch and trackpad for nothing — and gains a
             shell with real controls around it. Everything below is inert markup
             for every other variant. */
      $__deck = ($variant === 'rail');
      $__dset = static function (string $k, string $d = '') { return trim((string)($GLOBALS['_settings'][$k] ?? $d)); };
      $__deckAuto = $__deck && $__dset($kp . 'deck_autoplay') === 'on';
      $__deckIv   = max(2, min(30, (int)($GLOBALS['_settings'][$kp . 'deck_interval'] ?? 6)));
      $__deckDots = $__deck && $__dset($kp . 'deck_dots') !== 'off';
      $__deckPeek = $__deck && $__dset($kp . 'deck_peek') === 'on';
    ?>
    <?php if ($__deck): ?>
    <div class="hc-deck<?= $__deckPeek ? ' hc-deck-peek' : '' ?>" data-hc-deck
         data-deck-autoplay="<?= $__deckAuto ? '1' : '0' ?>" data-deck-interval="<?= (int)$__deckIv ?>">
      <div class="hc-deck-head">
        <div class="hc-deck-ctrls">
          <button type="button" class="hc-deck-btn" data-deck-prev
                  aria-label="<?= hc_esc($__t('deck_prev', 'Previous categories')) ?>">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
          </button>
          <button type="button" class="hc-deck-btn" data-deck-next
                  aria-label="<?= hc_esc($__t('deck_next', 'More categories')) ?>">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
          </button>
          <?php if ($__deckAuto): ?>
          <button type="button" class="hc-deck-btn hc-deck-play" data-deck-play aria-pressed="true"
                  data-label-play="<?= hc_esc($__t('deck_play', 'Play')) ?>"
                  data-label-pause="<?= hc_esc($__t('deck_pause', 'Pause')) ?>"
                  aria-label="<?= hc_esc($__t('deck_pause', 'Pause')) ?>">
            <svg class="hc-deck-i-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
            <svg class="hc-deck-i-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5l11 7-11 7z"/></svg>
          </button>
          <?php endif; ?>
        </div>
      </div>
    <?php endif; ?>
    <?php /* PHASE10K21_2026-08-12 — §26 ARCHITECTURE CONTROLS.
             Owner: "each architectural layout can have configuration options that
             genuinely relate to that layout… they must still use global visual
             styling." So every one of these governs STRUCTURE — how many article
             columns, how tight the rhythm, whether the section markers and the
             connectors are drawn, which side the rail sits, how narrow a module
             may get — and not one of them touches colour, type or the card
             system. Each is '' by default, meaning "the architecture decides",
             so an untouched workspace renders the design as drawn. */
      /* PHASE10K22_2026-08-12 — EVERY PRESENTATION CARRIES ITS OWN OPTIONS.
         Owner: "if you add new things, it should all be in each presentation and
         not put it in one." The first block reads `cat_arch_*`, the second
         `cat_arch_*_2`, the third `cat_arch_*_3`, so three presentations on one
         page can each be shaped independently. */
      $__bsfx = $block > 1 ? '_' . (int)$block : '';
      $__ag = static function (string $k) use ($__bsfx): string {
          return trim((string)($GLOBALS['_settings'][$k . $__bsfx] ?? ''));
      };
      $__archCls = '';
      $__archCols = $__ag($kp . 'arch_cols');
      if ($__ag($kp . 'arch_density') !== '') $__archCls .= ' hc-arch-d-' . hc_esc($__ag($kp . 'arch_density'));
      if ($__ag($kp . 'arch_numbers') === 'off') $__archCls .= ' hc-arch-nonum';
      if ($__ag($kp . 'arch_rules')   === 'off') $__archCls .= ' hc-arch-norule';
      $__archVars = '';
      /* PHASE10K28_2026-08-12 — a FIXED column count needs different track sizing
       * from the responsive default, so the sheet is told which one is in play.
       * Measured in Chrome: an explicit 3 with the default 20em track minimum
       * overflows the page horizontally below about 880px, and the phone
       * breakpoint at 640px is far too late to catch it. */
      if ($__archCols !== '') { $__archCls .= ' hc-arch-cols-set';
          $__archVars .= '--hc-archdir-cols:' . (int)$__archCols . ';--hc-cmddir-cols:' . (int)$__archCols . ';--hc-krail-cols:' . (int)$__archCols . ';'; }
      $__kgMin = (int)($GLOBALS['_settings'][$kp . 'kgrid_min' . $__bsfx] ?? 0);
      if ($__kgMin >= 180 && $__kgMin <= 640) $__archVars .= '--hc-kgrid-min:' . $__kgMin . 'px;';
    ?>
    <div class="hc-dir hc-dir-v-<?= hc_esc($variant) ?> hc-dir-c<?= $cols ?><?= $__archCls ?>"<?= $__archVars !== '' ? ' style="' . hc_esc($__archVars) . '"' : '' ?><?= $__deck ? ' data-deck-track tabindex="0" role="group" aria-roledescription="carousel" aria-label="' . hc_esc($__t('deck_region', 'Categories carousel')) . '"' : ' role="list"' ?>>
      <?php $__letter = null; ?>
      <?php
        /* PHASE9b_2026-08-09 — the cloud sizes each name by how much is behind it, so a
         * reader can see the shape of the knowledge base before reading a word. Five
         * buckets on the SQUARE ROOT of the count, not the count itself: one category
         * with 400 articles and forty with 5 would otherwise flatten every other name to
         * the smallest bucket. Computed once for the grid, not per card. */
        $__cloudMax = 0;
        if ($variant === 'cloud') {
            foreach ($cats as $__cc) $__cloudMax = max($__cloudMax, (int)($__cc['article_count'] ?? 0));
        }
      ?>
      <?php foreach (array_values($cats) as $__i => $cat):
        $__lead = ($variant === 'split' && $__i === 0);
        /* PHASE9_2026-08-09 — cards that are a HEADER and nothing else. A poster tile is
         * the icon and the name; a bento tile past the third is a 1x1 cell with no room
         * for previews. Deciding it here rather than hiding the body in CSS is what stops
         * the card shipping a chevron that toggles an empty box. */
        $__bodyless = in_array($variant, ['toc', 'matrix'], true)
                    || ($variant === 'bento' && $__i >= 3);
        $__weight = '';
        if ($variant === 'cloud' && $__cloudMax > 0) {
            $__wc = (int)($cat['article_count'] ?? 0);
            $__weight = ' hc-dir-w' . max(1, min(5, (int)ceil(sqrt(max(0, $__wc)) / sqrt($__cloudMax) * 5)));
        }
        $cid   = (int)($cat['id'] ?? 0);
        $name  = trim((string)($cat['name'] ?? ''));
        if ($cid <= 0 || $name === '') continue;
        $slug  = trim((string)($cat['slug'] ?? ''));
        $total = (int)($cat['article_count'] ?? 0);
        /* PHASE10C — this category's own cap wins over the global; 0 = all. */
        $__perThis = array_key_exists($cid, $__catLimits) ? $__catLimits[$cid] : $perCard;
        $rows  = $__bodyless ? []
               : ($__perThis === 0
                   ? ($byCat[$cid] ?? [])
                   : array_slice($byCat[$cid] ?? [], 0, $__perThis + ($__lead ? $featureExtra : 0)));
        $more  = max(0, $total - count($rows));

        /* PHASE10K18_2026-08-12 — ONLY THE LARGE BENTO TILE AUTO-FITS.
         *
         * Owner, at length: "regular bento cards obey the configured article
         * limit. Only the single large bento card automatically fills available
         * space… Do not accidentally make every bento card auto-fit."
         *
         * That tile is `:nth-child(1)` — the one CSS gives `span 2 / span 2`, so
         * it has roughly four times the room and was showing the same four rows
         * with a hole underneath. It renders a DEEPER POOL here; everything past
         * the configured count is marked and hidden in CSS, and the client shows
         * as many of them as actually fit. No number is hardcoded anywhere: the
         * pool is only an upper bound on what the client may reveal.
         *
         * `$more` deliberately stays measured against the CONFIGURED count, so
         * the continuation link renders exactly as it does on every other tile
         * and a workspace with JavaScript off sees precisely what it configured. */
        $__bentoFit = ($variant === 'bento' && $__i === 0 && !$__bodyless && $__perThis > 0 && $showArts);
        $__fitBase  = $__perThis;
        if ($__bentoFit) {
            $__pool = array_slice($byCat[$cid] ?? [], 0, max($__perThis, 24));
            if (count($__pool) > count($rows)) $rows = $__pool;
        }
        $href  = $slug !== '' ? hc_u('cat=' . rawurlencode($slug)) : '#';
        /* Each card owns its disclosure state. An id is needed for aria-controls,
         * and category ids are stable and unique on the page. */
        $panel = 'hc-dir-p-' . $cid;
      ?>
      <?php if ($variant === 'index'):
        /* mb_* so a non-Latin name groups under its own first character rather than a
         * mangled byte. Anything that is not a letter collects under # — digits and
         * punctuation would otherwise each open a group of one. */
        $__ch = mb_strtoupper(mb_substr($name, 0, 1));
        if (!preg_match('/^\p{L}$/u', $__ch)) $__ch = '#';
        if ($__ch !== $__letter):
          $__letter = $__ch; ?>
          <div class="hc-dir-letter" aria-hidden="true"><?= hc_esc($__letter) ?></div>
      <?php endif; endif; ?>
        <section class="hc-dir-card<?= hc_esc($__weight) ?><?= ($collapsible && !$__bodyless) ? ' is-collapsible' : '' ?><?= $__lead ? ' is-lead' : '' ?><?= ($startClosed && !$__lead) ? ' is-closed' : '' ?><?= ($hideFrom > 0 && $__i >= $hideFrom) ? ' hc-dir-card-more' : '' ?>" role="listitem">
          <div class="hc-dir-head">
            <?php if ($showNum): ?>
              <?php /* aria-hidden: the number is a visual ordering device. A screen
                       reader already announces list position, so voicing it again
                       just doubles it. */ ?>
              <span class="hc-dir-num" aria-hidden="true"><?= str_pad((string)($__i + 1), 2, '0', STR_PAD_LEFT) ?></span>
            <?php endif; ?>
            <?php if ($showIcon && trim((string)($cat['icon'] ?? '')) !== ''): ?>
              <span class="hc-dir-ico" aria-hidden="true"><?= hc_cat_icon_html($cat['icon']) ?></span>
            <?php endif; ?>
            <a class="hc-dir-title" href="<?= hc_esc($href) ?>">
              <span><?= hc_esc($name) ?></span>
              <?php /* PHASE10K17_2026-08-12 — the COMMAND DIRECTORY puts the article count
                       on the far right of every heading: that count is what makes a dense
                       directory scannable, so it is part of the architecture rather than
                       the optional "stats" chrome the other layouts treat it as. It still
                       renders through the same element and the same styling. */ ?>
              <?php if (($showStats || in_array($variant, ['cmddir', 'kgrid', 'spine', 'tree', 'krail', 'matrix'], true)) && $total > 0): ?>
                <span class="hc-dir-count"><?= (int)$total ?></span>
              <?php endif; ?>
            </a>
            <?php if ($collapsible && !$__bodyless): ?>
              <?php /* A real <button>, so it is focusable and operable from the
                      keyboard without a single line of extra JS for that part. */ ?>
              <?php /* aria-expanded MUST agree with the class the card ships with. If the
                       markup said "true" while the card rendered closed, the first click
                       would flip it to false and nothing would move. */ ?>
              <button type="button" class="hc-dir-toggle" aria-expanded="<?= ($startClosed && !$__lead) ? 'false' : 'true' ?>" aria-controls="<?= hc_esc($panel) ?>"
                      aria-label="<?= hc_esc($__t('toggle_section', 'Collapse or expand this section')) ?>">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
              </button>
            <?php endif; ?>
          </div>

          <?php /* PHASE10K29_2026-08-12 — A BODYLESS CARD SHIPS NO BODY.
                   toc and matrix are a header and nothing else. The wrapper was
                   still emitted and still carried the browse link, which CSS then
                   hid — bytes on every page load and a link a screen reader still
                   reads out of a display:none box. The card's own title is the
                   link these presentations navigate by. */ ?>
          <?php if (!$__bodyless): ?>
          <div class="hc-dir-body" id="<?= hc_esc($panel) ?>">
            <?php /* ONE grid child. The 0fr collapse technique sizes only the rows it
                     declares; with two children the second row is IMPLICIT and stays
                     auto-sized, so the card animated its top half and kept the rest
                     visible. Verified in Chrome before this wrapper existed. */ ?>
            <div class="hc-dir-inner">
            <?php $__desc = $__bodyless ? '' : trim((string)($cat['description'] ?? '')); ?>
            <?php if ($showDesc && $__desc !== ''): ?>
              <p class="hc-dir-desc"><?= hc_esc($__desc) ?></p>
            <?php endif; ?>
            <?php
              /* PHASE9d_2026-08-10 — cap the children so one card cannot tower over its
               * neighbour. 0 means "all", which is what every install rendered before this
               * control existed, so nothing changes until an operator chooses a number. */
              $__kids    = ($__bodyless || !$showKids) ? [] : ($childrenOf[$cid] ?? []);
              $__kidsAll = count($__kids);
              $__kidCap  = max(0, min(20, (int)($GLOBALS['_settings'][$kp . 'directory_subs'] ?? 0)));
              if ($__kidCap > 0 && $__kidsAll > $__kidCap) $__kids = array_slice($__kids, 0, $__kidCap);
              $__kidsMore = $__kidsAll - count($__kids);
            ?>
            <?php if ($__kids): ?>
              <?php /* Subcategories read as a chip row rather than more list rows, so a
                       reader can tell at a glance which links go DEEPER and which are
                       articles. Same reason the article links below are plain text. */ ?>
              <ul class="hc-dir-kids">
                <?php foreach ($__kids as $__k):
                  $__kn = trim((string)($__k['name'] ?? ''));
                  $__ks = trim((string)($__k['slug'] ?? ''));
                  if ($__kn === '' || $__ks === '') continue; ?>
                  <li><a href="<?= hc_esc(hc_u('cat=' . rawurlencode($__ks))) ?>"><?= hc_esc($__kn) ?>
                    <?php $__kc = (int)($__k['article_count'] ?? 0); if ($__kc > 0): ?><span><?= $__kc ?></span><?php endif; ?>
                  </a></li>
                <?php endforeach; ?>
                <?php if ($__kidsMore > 0): /* trimmed children are reported, never silently
                        dropped — the reader can see there is more and get to it. */ ?>
                  <li><a class="hc-dir-kidsmore" href="<?= hc_esc($href) ?>">+<?= (int)$__kidsMore ?></a></li>
                <?php endif; ?>
              </ul>
            <?php endif; ?>
            <?php if ($rows && $showArts): ?>
              <ul class="hc-dir-list"<?= $__bentoFit ? ' data-hc-bentofit="' . (int)$__fitBase . '"' : '' ?>>
                <?php $__ri = 0; foreach ($rows as $a):
                  $t = trim((string)($a['page_title'] ?? ''));
                  $s = trim((string)($a['slug'] ?? ''));
                  if ($t === '' || $s === '') continue;
                  /* Past the configured count these are candidates, hidden until
                     the client has measured the tile and knows how many fit. */
                  $__extra = ($__bentoFit && $__ri >= $__fitBase);
                  $__ri++; ?>
                  <?php $__liAtt = $__extra ? ' class="hc-bento-extra" hidden' : ''; ?>
                  <?php if ($showRowMeta):
                          /* excerpt is already on the row from listPublicArticles() — no extra
                           * query. Cut at a word boundary so a description never ends mid-word. */
                          $__ex = trim(preg_replace('/\s+/u', ' ', (string)($a['excerpt'] ?? '')));
                          if (function_exists('mb_strlen') && mb_strlen($__ex) > 96) {
                              $__ex = mb_substr($__ex, 0, 96);
                              $__cut = mb_strrpos($__ex, ' ');
                              if ($__cut !== false && $__cut > 40) $__ex = mb_substr($__ex, 0, $__cut);
                              $__ex .= '…';
                          } ?>
                  <li<?= $__liAtt ?>><a href="<?= hc_esc(hc_u('article=' . rawurlencode($s))) ?>">
                    <span class="hc-dir-rowtext">
                      <span class="hc-dir-rowtitle"><?= hc_esc($t) ?></span>
                      <?php if ($__ex !== ''): ?><span class="hc-dir-rowdesc"><?= hc_esc($__ex) ?></span><?php endif; ?>
                    </span>
                    <?php /* A styled span, not a <button>: the whole row is already the link, and a
                             button inside an anchor is invalid and unreachable by keyboard as a
                             separate control. aria-hidden so it is not announced twice. */ ?>
                    <span class="hc-dir-open" aria-hidden="true"><?= hc_esc($__t('open_row', 'Open')) ?> &rarr;</span>
                    <svg class="hc-dir-rowchev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
                  </a></li>
                  <?php else: ?>
                  <li<?= $__liAtt ?>><a href="<?= hc_esc(hc_u('article=' . rawurlencode($s))) ?>"><?= hc_esc($t) ?></a></li>
                  <?php endif; ?>
                <?php endforeach; ?>
              </ul>
            <?php endif; ?>

            <?php /* PHASE9d_2026-08-10 — asks whether the list was DRAWN, not whether rows
                     exist. In "Subcategories only" $rows is still computed and never
                     rendered, so a category with four or fewer articles lost its route
                     link — the one affordance every other card has. */ ?>
            <?php $__drewRows = ($rows && $showArts); ?>
            <?php /* PHASE10C — 'hide' suppresses the OVERFLOW link only; the browse
                     link a row-less card carries is navigation and always stays. */ ?>
            <?php if (($more > 0 || !$__drewRows) && !($__moreVariant === 'hide' && $__drewRows)): ?>
              <a class="hc-dir-more<?= $__moreVariant === 'pill' ? ' hc-dir-more-v-pill' : ($__moreVariant === 'quiet' ? ' hc-dir-more-v-quiet' : '') ?>" href="<?= hc_esc($href) ?>">
                <span><?= $more > 0
                        ? hc_esc($__moreLabel !== ''
                            ? str_replace('%d', (string)$total, $__moreLabel)
                            : sprintf($__t('see_all_n_articles', 'See all %d articles'), $total))
                        : hc_esc($__t('browse_category', 'Browse this category')) ?></span>
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
              </a>
            <?php endif; ?>
            </div>
          </div>
          <?php endif; ?>
        </section>
      <?php endforeach; ?>
    </div>
    <?php if ($__deck): ?>
      <?php if ($__deckDots): ?>
      <?php /* Built empty. The client fills it with one control per page once it
               knows how many cards fit — a server-rendered count would be a guess
               at the visitor's viewport. */ ?>
      <div class="hc-deck-dots" role="tablist" data-deck-dots
           aria-label="<?= hc_esc($__t('deck_pages', 'Carousel pages')) ?>"
           data-page-label="<?= hc_esc($__t('deck_page_n', 'Page {n}')) ?>"></div>
      <?php endif; ?>
    </div><?php /* .hc-deck */ ?>
    <?php endif; ?>
    <?php if ($collapsible): ?>
    <script>
    /* Delegated, and bound once. The Help Center soft-navigates between pages, and
       a per-element listener bound on DOMContentLoaded is exactly what stops
       working after the first soft nav — a trap this codebase has already paid
       for once with the quick-link tabs. */
    (function(){
      if (window.__hcDirBound) return;
      window.__hcDirBound = true;
      document.addEventListener('click', function(e){
        if (!e.target.closest) return;
        /* PHASE0.5_2026-08-09 — THE WHOLE HEADER TOGGLES, not just the chevron.
           Measured: the button is 28x28 inside a 529x57 header — THREE PERCENT of a
           surface that reads as entirely clickable. Clicking the card header did one of
           two things: nothing (dead space), or navigated away (the title is a link). The
           collapse worked the whole time; almost nobody could hit it.
           The title keeps its link, because "go to the category" is a real intent and the
           card also offers it at the bottom. Everything else in the header now discloses. */
        var b = e.target.closest('.hc-dir-toggle');
        var viaHead = false;
        if (!b) {
          var head = e.target.closest('.hc-dir-head');
          if (!head) return;
          b = head.querySelector('.hc-dir-toggle');
          if (!b) return;                                           // not a collapsible card
          viaHead = true;
        }

        /* THE TITLE IS PART OF THE HEADER, and excluding it was the wrong call: it spans
           451px of a 529px header, so "the whole header toggles" still left 85% of the row
           navigating away. The card already offers "See all N articles" at the bottom for
           that intent, so the header is the disclosure control and only the disclosure
           control.
           A plain left-click toggles; ctrl/cmd/middle-click and the keyboard still follow
           the href, so opening a category in a new tab keeps working and the link stays a
           real link for crawlers and screen readers. */
        if (viaHead && e.target.closest('.hc-dir-title')) {
          if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
          e.preventDefault();
        }
        var card = b.closest('.hc-dir-card');
        if (!card) return;
        var open = b.getAttribute('aria-expanded') !== 'false';
        b.setAttribute('aria-expanded', open ? 'false' : 'true');
        card.classList.toggle('is-closed', open);
      });
    })();
    </script>
    <?php endif; ?>
    <?php return (string)ob_get_clean();
}

/**
 * PHASE10K30_2026-08-13 — ONE PRESENTATION ENGINE, TWO SURFACES.
 *
 * Owner: "sub categories need to have all the options categories have."
 *
 * The home page had grown a full presentation stack — up to three presentations
 * in one section, a split point for each, gaps between the blocks, and the whole
 * directory/architecture family behind them. Subcategories had a tile style and
 * an icon toggle. Rather than copy any of that, this is the section dispatcher
 * both surfaces now call, keyed by PREFIX: 'cat' reads cat_layout, cat_split_at,
 * cat_arch_* and so on, 'subcat' reads the same names under subcat_. Every option
 * either surface gains from here on is gained by both, which is the only way the
 * two stay in step.
 *
 * $limit/$capped are the section's cap, and they stay the SECTION'S: each block
 * is handed the limit minus everything taken before it, or a limit of 6 across
 * three blocks would show eighteen.
 */
function hc_render_cat_presentations(array $rows, string $p, string $tileStyle, bool $tileIcons,
                                     array $childrenOf = [], int $limit = 0, bool $capped = false,
                                     string $tileExtra = ''): string {
    if (!$rows) return '';
    $S  = static function (string $k) { return $GLOBALS['_settings'][$k] ?? null; };
    $kp = $p . '_';

    $layout  = (string)($S($kp . 'layout') ?? 'tiles');
    $layout2 = trim((string)($S($kp . 'layout_2') ?? ''));
    $layout3 = trim((string)($S($kp . 'layout_3') ?? ''));
    $splitAt  = max(0, min(24, (int)($S($kp . 'split_at') ?? 0)));
    $splitAt2 = max(0, min(48, (int)($S($kp . 'split_at_2') ?? 0)));

    /* PHASE10K33_2026-08-13 — EVERY BLOCK OWNS ITS OWN SPACE, INCLUDING THE FIRST.
     *
     * Owner: "if you put margin below first presentation, it only affects the space
     * between the first and 2nd. if you put margin above it affects up. so if you
     * put below for 2nd presentation, it affects the next 3rd and above affects
     * between second and first."
     *
     * That is plain block-flow margin behaviour, and the old shape could not give
     * it: the FIRST block had no gap controls at all, and the two that existed were
     * pinned to wrappers around blocks two and three — so "below the second" and
     * "above the third" were two names for the same seam and their pixels ADDED.
     *
     * Now each block is wrapped in its own element carrying its own margin-top and
     * margin-bottom, in normal flow. Adjacent margins COLLAPSE, so a seam set from
     * either side gives the same answer and setting both gives the larger, never
     * the sum. Keys are cat_gap_top / cat_gap_bottom with the same _2 / _3 suffix
     * every other per-block option uses.
     *
     * The gaps follow the engine contract: '' emits NOTHING and the theme's own
     * spacing stands; '0' is a value — a deliberate flush join. */
    $gapStyle = static function (string $topKey, string $botKey) use ($S): string {
        $px = static function ($v): string {
            $v = trim((string) $v);
            if ($v === '' || !is_numeric($v)) return '';
            return (string) max(0, min(160, (int) round((float) $v)));
        };
        $t = $px($S($topKey) ?? ''); $b = $px($S($botKey) ?? '');
        $out = '';
        if ($t !== '') $out .= 'margin-top:' . $t . 'px;';
        if ($b !== '') $out .= 'margin-bottom:' . $b . 'px;';
        return $out === '' ? '' : ' style="' . hc_esc($out) . '"';
    };

    /* The five RETIRED keys stay listed: a saved value must still reach
     * hc_render_directory(), which is where it is remapped to its replacement. */
    $family = ['directory', 'accordion', 'compact', 'split', 'ribbon', 'panel', 'journey', 'editorial',
               'bento', 'console', 'rail', 'masonry', 'index', 'toc', 'tree', 'cloud', 'marquee', 'onboard',
               'archdir', 'blueprint', 'kgrid', 'campus', 'matrix', 'spine', 'krail', 'cmddir'];
    $valid = static function (string $l) use ($family): bool {
        return $l === 'tiles' || in_array($l, $family, true);
    };

    $one = function (array $set, string $lay, int $hideFrom, int $block = 1)
        use ($family, $S, $kp, $childrenOf, $tileStyle, $tileIcons, $tileExtra): string {
        if (in_array($lay, $family, true)) {
            /* PHASE_HC_SUBCAT_PARITY_2026-08-14 — the PREFIX, not a hardcoded 'cat_'.
               Subcategories were reading the category surface's hierarchy setting: the
               exact leak this renderer is prefixed to prevent. */
            $hier = (string)($S($kp . 'hierarchy') ?? 'flat');
            return hc_render_directory(
                $set,
                is_array($GLOBALS['_allArticles'] ?? null) ? $GLOBALS['_allArticles'] : [],
                (int)($S($kp . 'directory_columns') ?? 2),
                (int)($S($kp . 'directory_links') ?? 4),
                !in_array(strtolower((string)($S($kp . 'directory_collapsible') ?? '1')), ['', '0', 'off', 'false', 'no'], true),
                $lay,
                /* `nested` always needs the data; hc_render_directory() decides whether to
                 * paint it. `top` never does. */
                ($hier === 'nested'
                    || ($hier !== 'top'
                        && in_array(strtolower((string)($S($kp . 'card_content') ?? 'both')), ['subcategories', 'both'], true)))
                    ? $childrenOf : [],
                $hideFrom,
                hc_rule('category_articles'),
                $block,
                $kp
            );
        }
        return hc_cat_tiles($set, $tileStyle, $tileIcons, $tileExtra, $hideFrom);
    };

    /* A second presentation only means anything with somewhere to split, and a
     * third with no second is not a presentation at all. */
    $twoUp   = ($layout2 !== '' && $splitAt > 0 && count($rows) > $splitAt && $valid($layout2));
    $threeUp = ($twoUp && $layout3 !== '' && $splitAt2 > 0
                && count($rows) > ($splitAt + $splitAt2) && $valid($layout3));

    /* One wrapper per block, each carrying only its own margins. Block N's suffix
     * is the same '' / _2 / _3 the pickers and the architecture options use. */
    $block = static function (int $n, string $inner) use ($gapStyle, $kp): string {
        $sfx = $n > 1 ? '_' . $n : '';
        return '<div class="hc-cats-block hc-cats-block-' . $n . '"'
             . $gapStyle($kp . 'gap_top' . $sfx, $kp . 'gap_bottom' . $sfx) . '>' . $inner . '</div>';
    };

    if (!$twoUp) return $block(1, $one($rows, $layout, $capped ? $limit : 0));

    /* The lead is never capped — it is smaller than any sane limit by definition. */
    $out = $block(1, $one(array_slice($rows, 0, $splitAt), $layout, 0));
    if ($threeUp) {
        $out .= $block(2, $one(array_slice($rows, $splitAt, $splitAt2), $layout2,
                               $capped ? max(0, $limit - $splitAt) : 0, 2));
        $out .= $block(3, $one(array_slice($rows, $splitAt + $splitAt2), $layout3,
                               $capped ? max(0, $limit - $splitAt - $splitAt2) : 0, 3));
        return $out;
    }
    return $out . $block(2, $one(array_slice($rows, $splitAt), $layout2,
                                 $capped ? max(0, $limit - $splitAt) : 0, 2));
}

function hc_cat_tiles(array $cats, string $style, bool $icons, string $extra = '', int $hideFrom = 0): string {
    global $showStats, $homeCategoryTint, $homeCategoryIconTint;
    if (!in_array($style, ['card','badge','minimal','glow','list','bare','plain','detail'], true)) $style = 'card';
    $__tint = (($homeCategoryTint ?? 'plain') === 'theme') ? ' hc-cats-tint-theme' : '';
    $__tint .= (($homeCategoryIconTint ?? 'default') === 'theme') ? ' hc-cats-icotint-theme' : '';
    ob_start(); ?>
        <div class="hc-home-cats hc-cats-<?= hc_esc($style) ?><?= $__tint ?><?= $icons ? '' : ' hc-home-cats-noicon' ?><?= $extra !== '' ? ' ' . hc_esc($extra) : '' ?>" role="list">
          <?php foreach ($cats as $__ci => $cat):
            $__over  = ($hideFrom > 0 && $__ci >= $hideFrom);
            $ccount = (int)($cat['article_count'] ?? 0);
            $cname  = (string)($cat['name'] ?? '');
            $cicon  = trim((string)($cat['icon'] ?? '')) !== '' ? (string)$cat['icon'] : '📁';
            $cletter= function_exists('mb_strtoupper') ? mb_strtoupper(mb_substr(trim($cname) !== '' ? $cname : '?', 0, 1)) : strtoupper(substr($cname !== '' ? $cname : '?', 0, 1));
            $curl   = hc_u('cat=' . urlencode((string)($cat['slug'] ?? '')));
            $__hasRealIcon = trim((string)($cat['icon'] ?? '')) !== '';
          ?>
          <a class="hc-home-cat<?= $__over ? ' hc-home-cat-more' : '' ?><?= $__hasRealIcon ? ' hc-cat-has-icon' : '' ?>" href="<?= hc_esc($curl) ?>" role="listitem">
            <?php if ($showStats && $ccount > 0): ?><span class="hc-home-cat-num" aria-hidden="true"><?= $ccount ?></span><?php endif; ?>
            <?php if ($icons):
              $iconHtml = hc_cat_icon_html($cicon);
              if ($iconHtml === '') $iconHtml = hc_esc('📁');
              $isImgIco = (strpos($iconHtml, '<img') === 0 || strpos($iconHtml, '<svg') === 0);
            ?>
              <span class="hc-home-cat-ico<?= $isImgIco ? ' hc-ico-has-img' : '' ?>" aria-hidden="true"><?= $iconHtml ?></span>
              <span class="hc-home-cat-letter" aria-hidden="true"><?= hc_esc($cletter) ?></span>
            <?php endif; ?>
            <span class="hc-home-cat-body">
              <span class="hc-home-cat-name"><?= hc_esc($cname) ?></span>
              <?php if (trim((string)($cat['description'] ?? '')) !== ''): ?><span class="hc-home-cat-desc"><?= hc_esc((string)$cat['description']) ?></span><?php endif; ?>
              <?php if ($showStats && $ccount > 0): ?><span class="hc-home-cat-count"><?= $ccount ?> article<?= $ccount !== 1 ? 's' : '' ?></span><?php endif; ?>
            </span>
            <span class="hc-home-cat-arrow" aria-hidden="true">→</span>
          </a>
          <?php endforeach; ?>
        </div>
    <?php return (string)ob_get_clean();
}

/**
 * THE HELP CENTRE'S OWN ASSISTANT CONFIG.
 *
 * Deliberately NOT the portal's published block. The two surfaces answer
 * different people in different words, and a workspace may want the assistant
 * on one and not the other, so each carries its own settings. This builds the
 * shape the shared module expects out of the help centre's own keys — the
 * module is shared, the configuration is not.
 */
function hc_assistant_config(array $settings): array {
    $b = static fn (string $k, bool $d) => !isset($settings[$k])
        ? $d : filter_var($settings[$k], FILTER_VALIDATE_BOOLEAN);
    $t = static fn (string $k, string $d = '') => trim((string)($settings[$k] ?? $d));
    /* PHASE_HC_ASSISTANT_I18N_2026-08-15 — the FRONT-END translator, reached through
     * $GLOBALS because this is a function and does not inherit the outer scope. The
     * five defaults below used to be English literals read by $t, which is a SETTINGS
     * reader, so an operator who never touched the panel got English in all 40
     * languages. Falls back to the identity function if the catalogue is unavailable,
     * exactly as hc_links_block() does. */
    $tr = is_callable($GLOBALS['__t'] ?? null)
        ? $GLOBALS['__t']
        : static fn (string $k, string $d = '') => $d;
    /* PHASE_HC_ASSISTANT_I18N_FIX_2026-08-15 — $t IS A SETTINGS READER, AND ITS DEFAULT
     * ONLY FIRES WHEN THE KEY IS ABSENT.
     *
     * The first attempt wrapped the translator INSIDE the settings reader as that reader's
     * default argument, which reads correctly and does nothing: the Studio writes defaults INTO
     * the blob, so the key is present-and-equal, `$t` returns the stored English, and the
     * translator's answer is computed and thrown away. Verified live — the French page
     * shipped "Ask something", "Ask us anything", "Type your question…".
     *
     * This is the same contract $__titleTxt (help.php:1400) already uses: the operator's
     * own words win, but a value equal to the shipped default means NOBODY CHOSE IT, so it
     * belongs to the catalogue. */
    $tx = static function (string $key, string $catKey, string $en) use ($settings, $tr): string {
        $v = trim((string)($settings[$key] ?? ''));
        if ($v !== '' && $v !== $en) return $v;

        return (string) $tr($catKey, $en);
    };

    $launcher = strtolower($t('hc_assistant_launcher', 'search'));
    if (!in_array($launcher, ['bubble', 'search', 'bar', 'card', 'tab'], true)) $launcher = 'search';
    /* The glyph, held to what PXA_ICONS in portal-assistant.js actually defines. An
       unknown name there resolves to undefined and silently falls through, so a typo
       used to look like a working setting that just would not take effect. */
    $asstIcon = strtolower($t('hc_assistant_icon', 'search'));
    if (!in_array($asstIcon, ['search','sparkle','chat','question','lifebuoy','robot','book'], true)) $asstIcon = 'search';
    $pos = strtolower($t('hc_assistant_position', 'bottom-right'));
    if (!in_array($pos, ['bottom-right','bottom-left','bottom-center','middle-right','middle-left','top-right','top-left'], true)) {
        $pos = 'bottom-right';
    }
    /* One per line in the box, a list to the module. */
    $sugg = array_values(array_filter(array_map('trim',
        preg_split('/\r\n|\r|\n/', $t('hc_assistant_suggestions')) ?: []), static fn ($v) => $v !== ''));

    return [
        'enabled'        => $b('hc_assistant_enabled', true),
        'ai'             => $b('hc_assistant_ai', true),
        'scope'          => 'all',
        'launcher'       => $launcher,
        'launcher_label' => $tx('hc_assistant_label', 'hc_asst_label', 'Ask something'),
        'launcher_sub'   => $tx('hc_assistant_sub', 'hc_asst_sub', 'Answers, or open a request'),
        'position'       => $pos,
        'accent'         => $t('hc_assistant_accent'),
        'icon'           => $asstIcon,
        'title'          => $tx('hc_assistant_title', 'hc_asst_title', 'Ask us anything'),
        'greeting'       => $t('hc_assistant_greeting'),
        'placeholder'    => $tx('hc_assistant_placeholder', 'hc_asst_placeholder', 'Type your question…'),
        'no_answer'      => $t('hc_assistant_no_answer'),
        'suggestions'    => $sugg,
        'ticket_handoff' => $b('hc_assistant_handoff', true),
        'typo_tolerant'  => $b('hc_assistant_typo', true),
        'reply_language' => $t('hc_assistant_language', 'visitor') === 'page' ? 'portal' : 'visitor',
        'footer_note'    => $t('hc_assistant_footer_note'),
        'live_chat'      => $b('hc_assistant_live', true),
        'live_label'     => $tx('hc_assistant_live_label', 'hc_asst_live', 'Talk to a person'),
        'live_intro'     => '',
        'offline_note'   => '',
    ];
}

/**
 * AUTO ICONS, AS FONT AWESOME.
 *
 * HcAutoIcon already does the hard part — it reads the words of a label and
 * decides which CONCEPT it is ("billing", "domain", "security", …). What it
 * returns is an inline SVG, and this page dresses its icons with Font Awesome,
 * which is already loaded. So the concept is reused and only the rendering
 * changes: one map, no second guess at what a label means.
 *
 * An operator who types their own icon always wins; this only fills the blank.
 */
function hc_quick_auto_icon(string $label): string {
    static $map = [
        'security' => 'fa-shield-halved', 'billing'  => 'fa-credit-card',
        'start'    => 'fa-rocket',        'account'  => 'fa-user',
        'team'     => 'fa-users',         'email'    => 'fa-envelope',
        'domain'   => 'fa-globe',         'hosting'  => 'fa-server',
        'database' => 'fa-database',      'files'    => 'fa-folder-open',
        'website'  => 'fa-window-maximize','api'     => 'fa-code',
        'trouble'  => 'fa-triangle-exclamation', 'support' => 'fa-headset',
        'learn'    => 'fa-graduation-cap','news'     => 'fa-bullhorn',
        'reports'  => 'fa-chart-line',    'store'    => 'fa-cart-shopping',
        'mobile'   => 'fa-mobile-screen', 'settings' => 'fa-gear',
        'policy'   => 'fa-file-contract', 'default'  => 'fa-circle-info',
    ];
    $concept = 'default';
    if (class_exists('\\OpsIQ\\Kb\\HcAutoIcon')) {
        try { $concept = (string)\OpsIQ\Kb\HcAutoIcon::conceptFor($label); } catch (\Throwable $e) { $concept = 'default'; }
    }
    return 'fa-solid ' . ($map[$concept] ?? $map['default']);
}

/**
 * NEWS AND UPDATES — the feed.
 *
 * Draws from the News and Announcement categories and nothing else. Categories
 * are matched by slug first (the stable identifier) and by name as a fallback,
 * so an operator who renamed "Announcements" still gets their announcements.
 *
 * The two feeds are MERGED FAIRLY, not concatenated: taking one from each in
 * turn until the limit is reached means a category with eighteen posts cannot
 * push a quieter one out of the section altogether. Titles are deduped across
 * both (the same post filed twice is one row), and the result is newest-first.
 */
function hc_news_feed(string $siteKey, string $feed, int $limit): array {
    $wanted = [
        'news'         => ['/^news(-|$)/', '/^news[- ]and[- ]updates?$/', '/\bnews\b/', '/\bupdates?\b/', '/\brelease/', '/\bchangelog/'],
        'announcement' => ['/^announce/', '/\bannouncement/'],
    ];
    if ($feed === 'news' || $feed === 'announcement') $wanted = [$feed => $wanted[$feed]];

    $cats = [];
    try { $cats = (array)\OpsIQ\Kb\HelpCenter::listCategories($siteKey); } catch (\Throwable $e) { return []; }

    $pool = [];
    $seenTitle = [];
    $seenId = [];
    foreach ($wanted as $key => $patterns) {
        $ids = [];
        foreach ($cats as $c) {
            if (!is_array($c)) continue;
            $hay = strtolower(trim((string)($c['slug'] ?? '')) . ' ' . trim((string)($c['name'] ?? '')));
            foreach ($patterns as $re) {
                if (preg_match($re, strtolower((string)($c['slug'] ?? ''))) || preg_match($re, $hay)) {
                    $ids[(int)($c['id'] ?? 0)] = true;
                    break;
                }
            }
        }
        $rows = [];
        $dupeRows = [];
        foreach (array_keys($ids) as $cid) {
            if ($cid <= 0) continue;
            try { $got = (array)\OpsIQ\Kb\HelpCenter::listPublicArticles($siteKey, $cid, max($limit * 4, 24)); }
            catch (\Throwable $e) { continue; }
            /* Article rows carry category_id and nothing else about the category,
             * so the name and slug are stamped on here — the meta line under each
             * title needs them, and so does the "See all" destination. */
            $cName = ''; $cSlug = '';
            foreach ($cats as $c) {
                if (is_array($c) && (int)($c['id'] ?? 0) === $cid) {
                    $cName = trim((string)($c['name'] ?? ''));
                    $cSlug = trim((string)($c['slug'] ?? ''));
                    break;
                }
            }
            foreach ($got as $r) {
                if (!is_array($r)) continue;
                /* Identity is the article id. Dedupe by TITLE only across the two
                 * feeds — that catches one article cross-listed in both News and
                 * Announcements, which is the case the title check was written
                 * for. Inside a single feed it was collapsing genuinely distinct
                 * articles that happen to share a title (an import can leave a
                 * dozen rows all called "Announcements - Nabtech"), which is why
                 * a limit of 6 rendered 5. */
                $aid = (int)($r['id'] ?? 0);
                if ($aid > 0 && isset($seenId[$aid])) continue;
                $t = mb_strtolower(trim((string)($r['page_title'] ?? '')));
                if ($t !== '' && ($seenTitle[$t] ?? $key) !== $key) continue;
                if ($aid > 0) $seenId[$aid] = true;
                $r['category_name'] = $cName;
                $r['category_slug'] = $cSlug;
                /* Distinct titles first, repeats held back. A feed of six rows all
                 * reading the same headline is worse than a short one, but a repeat
                 * still beats an empty slot — so they backfill only once every
                 * distinct title has been used. */
                if ($t !== '' && isset($seenTitle[$t])) { $dupeRows[] = $r; continue; }
                if ($t !== '') $seenTitle[$t] = $key;
                $rows[] = $r;
            }
        }
        $rows = array_merge($rows, $dupeRows);
        if ($rows) $pool[$key] = $rows;
    }
    if (!$pool) return [];

    /* Fair merge: one from each feed in turn, until the limit is reached. */
    $out = [];
    $i = 0;
    while (count($out) < $limit) {
        $took = false;
        foreach ($pool as $k => $rows) {
            if (!isset($rows[$i])) continue;
            $out[] = $rows[$i];
            $took = true;
            if (count($out) >= $limit) break;
        }
        if (!$took) break;
        $i++;
    }
    usort($out, static fn ($x, $y) =>
        strtotime((string)($y['last_changed_at'] ?? $y['created_at'] ?? '')) <=>
        strtotime((string)($x['last_changed_at'] ?? $x['created_at'] ?? '')));
    return array_slice($out, 0, $limit);
}

/**
 * NEWS AND UPDATES — the section.
 *
 * The unified design: a heading, then rows in columns, each row a bold
 * underlined title over a quiet "date · category" line, separated by hairlines,
 * and one "See all" link at the bottom left. Everything is drawn from the page's
 * own design tokens, so it follows the theme, dark mode and the brand colour
 * rather than carrying colours of its own.
 */
/* PHASE8_2026-08-10 — the FAQ accordion.
 *
 * Reads help_class='faq' from the SAME article table as everything else — the plan is
 * explicit that there is no second FAQ database. Returns '' when nothing carries the
 * class, which is how the block stays off until a content pass has run; an empty accordion
 * under a heading reads as broken, not as unconfigured.
 *
 * <details>/<summary> and no JavaScript: it opens with JS off, the browser owns the state,
 * and it is keyboard-operable and announced correctly without a line of ARIA from us. A
 * hand-rolled accordion here would be a worse version of something the platform gives away.
 */
/* PHASE_HC_ANNOUNCE_BAR_2026-08-14 — THE ANNOUNCEMENT BAR.
 *
 * The portal's announcement, converted onto the help centre's own settings: one line the
 * operator types across the top of every page. It replaced a home-page strip that listed
 * articles classed 'announcement' — which no operator could put a line into, and which on
 * the live workspace rendered nothing at all for weeks.
 *
 * The logic is OpsIQ\Kb\HcAnnounce so it can be tested: nothing in this file can be,
 * because requiring it renders a page. $GLOBALS on purpose — like hc_render_nav(), this
 * does not import $_settings (the house trap).
 */
function hc_render_announce_bar(): string {
    return \OpsIQ\Kb\HcAnnounce::render(
        (array)($GLOBALS['_settings'] ?? []),
        (string)($GLOBALS['_navStyle'] ?? ''),
        is_callable($GLOBALS['__t'] ?? null) ? $GLOBALS['__t'] : null
    );
}
/**
 * PHASE_HC_FAQ_VARIANTS_2026-08-27 — the FAQ presentations, in one place.
 *
 * Declared as a constant rather than repeated as a literal, because it was already
 * written out three times — here, in the Studio's picker, and in HcSettingsRegistry's
 * enum — and a fourth copy is how a variant ends up selectable in the Studio and
 * silently rewritten to `accordion` on the page.
 */
const HC_FAQ_STYLES = ['accordion', 'plain', 'cards', 'split', 'spotlight', 'quilt', 'ledger', 'glass'];

/**
 * PHASE_HC_FAQ_SPLIT_COLUMNS_2026-08-27 — presentations laid out as real columns.
 *
 * Anything not listed here is a single flow. A number here means the items are DEALT
 * into that many lists so each one packs on its own; see the long note in
 * hc_render_faq for why a grid cannot do this and what the mobile fallback depends on.
 */
const HC_FAQ_COLUMNS = ['split' => 2, 'quilt' => 3];

function hc_render_faq(array $rows): string {
    global $__t, $_layout, $_settings;
    if (!$rows) return '';

    $style = strtolower(trim((string)($_settings['hc_faq_style'] ?? 'accordion')));
    if (!in_array($style, HC_FAQ_STYLES, true)) $style = 'accordion';

    $title = trim((string)($_settings['hc_faq_title'] ?? ''));
    if ($title === '') $title = $__t('faq_title', 'Frequently asked questions');

    /* The first one opens, so the section never arrives as a stack of closed bars with
     * nothing to read — the same reasoning as the quick-links tab rail. */
    $openFirst = !in_array(strtolower((string)($_settings['hc_faq_open_first'] ?? '1')), ['', '0', 'off', 'false', 'no'], true);

    /* PHASE_HC_FAQ_VARIANTS_2026-08-27 — "the first one" is the top of each COLUMN.
     *
     * Owner, on the two-column directory: *"since its split, it should open the both
     * split first ones."* In a single flow the first item is the top of the section and
     * opening it is obvious; in a columned presentation each column has a top, and
     * opening only the leftmost leaves the rest of the first line looking unfinished.
     *
     * A columned presentation therefore opens the first item of every column — done
     * where the columns are emitted, below. This count is for the single-flow
     * presentations, where the answer is simply one. */
    $openCount = 1;

    /* Build the items first, then decide how to lay them out. The two-column
     * presentation needs REAL columns (see below), and that cannot be done while
     * echoing one flat list. */
    $items = [];
    foreach (array_values($rows) as $__a) {
        $__q = trim((string)($__a['page_title'] ?? ''));
        $__s = trim((string)($__a['slug'] ?? ''));
        if ($__q === '') continue;
        /* The answer is the excerpt, not the whole article: an accordion that unfolds
           2,000 words is a page, not an answer. The link goes to the full one.
           PHASE_PORTAL_P13 audit — unless the article is customers-only for this
           reader: then the "answer" would be 720 chars of the restricted body. */
        $__ans = \OpsIQ\Kb\HelpCenter::canReadArticle($__a)
            ? trim(preg_replace('/\s+/u', ' ', (string)($__a['excerpt'] ?? '')))
            : (string)$__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.');
        if ($__ans === '') continue;
        $items[] = ['q' => $__q, 'slug' => $__s, 'a' => $__ans];
    }
    if (!$items) return '';

    /* PHASE_HC_SEO_2026-08-27 — publish what this page ACTUALLY renders, for FAQPage.
     * Recorded here rather than re-queried in the head, because FAQPage markup that
     * describes questions a visitor cannot see is a manual action, not a rich result.
     * If the block does not render, the list stays empty and no FAQPage is emitted. */
    $GLOBALS['__hcFaqRendered'] = array_map(
        static fn(array $it): array => ['q' => $it['q'], 'a' => $it['a']],
        $items
    );

    $renderItem = static function (array $it, bool $open) use ($__t): string {
        $more = $it['slug'] !== ''
            ? '<a class="hc-faq-more" href="' . hc_esc(hc_u('article=' . rawurlencode($it['slug']))) . '">'
              . hc_esc((string)$__t('faq_read_full', 'Read the full answer')) . ' &rarr;</a>'
            : '';
        return '<details class="hc-faq-item"' . ($open ? ' open' : '') . '>'
             . '<summary class="hc-faq-q">'
             . '<span class="hc-faq-qtext">' . hc_esc($it['q']) . '</span>'
             . '<span class="hc-faq-chev" aria-hidden="true"></span>'
             . '</summary>'
             . '<div class="hc-faq-a"><p>' . hc_esc($it['a']) . '</p>' . $more . '</div>'
             . '</details>';
    };

    /* PHASE_HC_FAQ_SPLIT_COLUMNS_2026-08-27 — TWO COLUMNS, NOT A TWO-WIDE GRID.
     *
     * Owner, on the two-column presentation: *"still creating the space, bring the
     * other side down."*
     *
     * It was one grid, two per row. Stopping the cards stretching (align-items:start)
     * fixed the tall empty box, but a grid ROW is still as tall as its tallest cell, so
     * a long answer on the left left a column of dead space on the right and the next
     * question waited below it instead of moving up. Independent columns cannot be
     * expressed as rows — the browser has no masonry to give here — so the items are
     * dealt into two lists and each one packs on its own.
     *
     * SEQUENTIAL CHUNKS, not alternating. Both fill the columns evenly; only this one
     * survives the mobile breakpoint, where the columns flatten back into one and the
     * DOM order becomes the reading order. Dealt alternately, a phone would read
     * 1, 3, 5, 7, 2, 4, 6, 8.
     *
     * `quilt` gets the same treatment for the same reason — it is a grid too, and its
     * rows left the same dead band under a long answer. It is listed in HC_FAQ_COLUMNS
     * rather than special-cased, so the next multi-column presentation inherits this
     * instead of re-reporting it.
     *
     * The open-on-arrival item is then the first of EACH column rather than the first
     * two of the list, which is what the owner asked for in the same breath: *"since
     * its split, it should open the both split first ones."* */
    $columns = null;
    $colCount = HC_FAQ_COLUMNS[$style] ?? 0;
    if ($colCount > 1 && count($items) > 1) {
        $per     = (int)ceil(count($items) / $colCount);
        $columns = array_values(array_filter(array_chunk($items, $per)));
    }

    ob_start(); ?>
      <section class="hc-faq-shell hc-faq-v-<?= hc_esc($style) ?>">
        <div class="hc-sec hc-sec-<?= hc_esc($_layout) ?>"><span class="hc-sec-label hc-label-faq"><?= hc_esc($title) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>
        <div class="hc-faq-list">
          <?php if ($columns !== null): ?>
            <?php foreach ($columns as $__col): ?>
              <?php if (!$__col) continue; ?>
              <div class="hc-faq-col">
                <?php foreach ($__col as $__ci => $__it) echo $renderItem($__it, $openFirst && $__ci === 0); ?>
              </div>
            <?php endforeach; ?>
          <?php else: ?>
            <?php foreach ($items as $__i => $__it) echo $renderItem($__it, $openFirst && $__i < $openCount); ?>
          <?php endif; ?>
        </div>
      </section>
    <?php return (string)ob_get_clean();
}
function hc_render_news(array $rows): string {
    global $homeNewsTitle, $homeNewsColumns, $homeNewsShowDate, $homeNewsShowCat, $homeNewsViewAll, $__t, $_layout;
    if (!$rows) return '';
    $cols = max(1, min(3, (int)($homeNewsColumns ?? 2)));
    ob_start(); ?>
      <section class="hc-news-shell hc-news-shell-<?= hc_esc($_layout) ?>">
        <div class="hc-news-card">
          <?php if (($homeNewsTitle ?? '') !== ''): ?>
          <h2 class="hc-news-title"><?= hc_esc($homeNewsTitle) ?></h2>
          <?php endif; ?>
          <div class="hc-news-list hc-news-cols-<?= (int)$cols ?>">
            <?php foreach ($rows as $r):
              $slug = (string)($r['slug'] ?? '');
              if ($slug === '') continue;
              $when = (string)($r['last_changed_at'] ?? $r['created_at'] ?? '');
              $ts   = $when !== '' ? strtotime($when) : 0;
              $cat  = trim((string)($r['category_name'] ?? ''));
            ?>
            <article class="hc-news-item">
              <a class="hc-news-link" href="<?= hc_esc(hc_u('article=' . urlencode($slug))) ?>"><?= hc_esc((string)($r['page_title'] ?? '')) ?></a>
              <?php if (($homeNewsShowDate && $ts) || ($homeNewsShowCat && $cat !== '')): ?>
              <div class="hc-news-meta">
                <?php if ($homeNewsShowDate && $ts): ?><span><?= hc_esc(hc_date_local($ts)) ?></span><?php endif; ?>
                <?php if ($homeNewsShowCat && $cat !== ''): ?><span><?= hc_esc(hc_cat_name_local($cat)) ?></span><?php endif; ?>
              </div>
              <?php endif; ?>
            </article>
            <?php endforeach; ?>
          </div>
          <?php
          /* "See all" goes where the operator points it; with nothing set it goes
             to the category most of these rows came from, which is what a reader
             expects from a section headed by one topic. */
          $__seeAll = trim((string)($homeNewsViewAll ?? ''));
          if ($__seeAll === '') {
              $__tally = [];
              foreach ($rows as $r) {
                  $cs = trim((string)($r['category_slug'] ?? ''));
                  if ($cs !== '') $__tally[$cs] = ($__tally[$cs] ?? 0) + 1;
              }
              if ($__tally) { arsort($__tally); $__seeAll = hc_u('cat=' . urlencode((string)array_key_first($__tally))); }
          }
          if ($__seeAll !== ''): ?>
          <a class="hc-news-all" href="<?= hc_esc($__seeAll) ?>"><?= hc_esc($__t('see_all', 'See all')) ?> <span aria-hidden="true">&rarr;</span></a>
          <?php endif; ?>
        </div>
      </section>
    <?php return (string)ob_get_clean();
}

/**
 * QUICK LINKS — the section.
 *
 * `tiles` is the plain grid of shortcuts. `tabs` is the panel design: the items
 * become tabs down the side and the panel lists the chosen topic's articles.
 * Tabs are native radio inputs and CSS, so the panel switches with no JavaScript
 * and remains keyboard-operable.
 */
/**
 * THE CTA BAND.
 *
 * One band, ten templates. The template decides the material (paper, gradient,
 * dark command, glass, framed…); size, width, height, radius, shadow and
 * alignment are INDEPENDENT of it, so an operator who likes a template but
 * wants it quieter never has to abandon the template to get there.
 *
 * Title, subhead and body are rendered with their newlines intact — a break in
 * a headline is an authoring decision, and a single string that wraps wherever
 * the container ends cannot express it.
 */

function hc_render_cta(array $c): string {
    global $homeCtaVariant, $homeCtaSize, $homeCtaWidth, $homeCtaAlign,
           $homeCtaRadius, $homeCtaShadow, $homeCtaHeight, $homeCtaSides;
    /* Nothing to say and nowhere to go: render nothing rather than an empty band. */
    if (($c['title'] ?? '') === '' && ($c['body'] ?? '') === ''
        && (($c['p_label'] ?? '') === '' || ($c['p_url'] ?? '') === '')) return '';

    /* Newlines are the author's line breaks. Escape FIRST, then convert, so the
     * copy can never inject markup through a break. */
    $lines = static fn (string $v): string => nl2br(hc_esc($v), false);

    /* THE SHEET'S OWN CLASS NAMES. Mine were invented (`-sz-`, `-al-`) and
     * matched nothing, so size and alignment silently did nothing. */
    $cls = 'hce-cta hce-cta-' . hc_esc($homeCtaVariant)
         . ($homeCtaSize !== 'md' ? ' hce-cta-size-' . hc_esc($homeCtaSize) : '')
         . ' hcx-cta-w-' . hc_esc($homeCtaWidth)
         . ($homeCtaAlign === 'center' ? ' hce-cta-align-center' : '')
         . ($homeCtaHeight !== 'auto' ? ' hce-cta-h-' . hc_esc($homeCtaHeight) : '')
         . ($homeCtaSides !== 'auto' ? ' hcx-cta-s-' . hc_esc($homeCtaSides) : '')
         . ($homeCtaRadius !== 'inherit' ? ' hce-cta-r-' . hc_esc($homeCtaRadius) : '')
         . ($homeCtaShadow !== 'inherit' ? ' hce-cta-sh-' . hc_esc($homeCtaShadow) : '');

    ob_start(); ?>
      <section class="<?= $cls ?>"<?= ($c['image'] ?? '') !== '' ? ' style="--hce-cta-image:url(' . hc_esc(hc_asset_url($c['image'])) . ')"' : '' ?>>
        <div class="hce-cta-inner">
          <div class="hce-cta-copy">
            <?php if (($c['badge'] ?? '') !== ''): ?><span class="hce-cta-badge"><?= hc_esc($c['badge']) ?></span><?php endif; ?>
            <?php if (($c['eyebrow'] ?? '') !== ''): ?><span class="hce-cta-eyebrow"><?= hc_esc($c['eyebrow']) ?></span><?php endif; ?>
            <?php if (($c['icon'] ?? '') !== ''): ?><span class="hce-cta-ico" aria-hidden="true"><?= hc_cat_icon_html((string)$c['icon']) ?></span><?php endif; ?>
            <?php if (($c['title'] ?? '') !== ''): ?><h2 class="hce-cta-title"><?= $lines((string)$c['title']) ?></h2><?php endif; ?>
            <?php if (($c['subhead'] ?? '') !== ''): ?><p class="hce-cta-subhead"><?= $lines((string)$c['subhead']) ?></p><?php endif; ?>
            <?php if (($c['body'] ?? '') !== ''): ?><p class="hce-cta-body"><?= $lines((string)$c['body']) ?></p><?php endif; ?>
          </div>
          <?php if ((($c['p_label'] ?? '') !== '' && ($c['p_url'] ?? '') !== '')
                 || (($c['s_label'] ?? '') !== '' && ($c['s_url'] ?? '') !== '')): ?>
          <div class="hce-cta-actions">
            <?php if (($c['p_label'] ?? '') !== '' && ($c['p_url'] ?? '') !== ''): ?>
            <a class="hce-btn hce-cta-btn" href="<?= hc_esc($c['p_url']) ?>"><?= hc_esc($c['p_label']) ?></a>
            <?php endif; ?>
            <?php if (($c['s_label'] ?? '') !== '' && ($c['s_url'] ?? '') !== ''): ?>
            <a class="hce-btn hce-btn-secondary hce-cta-btn2" href="<?= hc_esc($c['s_url']) ?>"><?= hc_esc($c['s_label']) ?></a>
            <?php endif; ?>
          </div>
          <?php endif; ?>
          <?php if (($c['note'] ?? '') !== ''): ?><p class="hce-cta-note"><?= hc_esc($c['note']) ?></p><?php endif; ?>
        </div>
      </section>
    <?php return (string)ob_get_clean();
}

/* ── THE CTA COMPONENT, ACROSS SURFACES ──────────────────────────────────────
 * PHASE10K8_2026-08-11 — one component, five surfaces, independent settings.
 *
 * hc_cta_slot() is the ONLY way a page emits the band. A surface calls it at
 * each place the band may sit, naming that place; the function renders exactly
 * when the operator has enabled that surface AND chosen that place. Nothing is
 * hardcoded into a page any more: adding a placement means calling this in one
 * more spot, not writing a second CTA.
 *
 * hc_render_cta() reads its dress from globals, so a surface's overrides are
 * swapped in and put back around the call. Leaving them changed would alter the
 * home band on any request that renders both.
 */
function hc_cta_slot(string $surface, string $slot): string {
    global $homeCta, $homeCtaVariant, $homeCtaSize, $homeCtaWidth, $homeCtaAlign,
           $homeCtaRadius, $homeCtaShadow, $homeCtaHeight, $homeCtaSides;

    $surfaces = $GLOBALS['__hcCtaSurfaces'] ?? null;
    if (!is_array($surfaces) || !\OpsIQ\Kb\HcCta::shows($surfaces, $surface, $slot)) return '';
    $cfg = $surfaces[$surface];

    /* Swap in this surface's dress; blank keeps the home band's own. */
    $prev = [$homeCtaVariant, $homeCtaSize, $homeCtaWidth, $homeCtaAlign,
             $homeCtaRadius, $homeCtaShadow, $homeCtaHeight, $homeCtaSides];
    if (($cfg['variant'] ?? '') !== '') $homeCtaVariant = $cfg['variant'];
    if (($cfg['size']    ?? '') !== '') $homeCtaSize    = $cfg['size'];
    if (($cfg['width']   ?? '') !== '') $homeCtaWidth   = $cfg['width'];
    if (($cfg['align']   ?? '') !== '') $homeCtaAlign   = $cfg['align'];
    if (($cfg['radius']  ?? '') !== '') $homeCtaRadius  = $cfg['radius'];
    if (($cfg['shadow']  ?? '') !== '') $homeCtaShadow  = $cfg['shadow'];
    if (($cfg['height']  ?? '') !== '') $homeCtaHeight  = $cfg['height'];
    if (($cfg['sides']   ?? '') !== '') $homeCtaSides   = $cfg['sides'];

    $html = hc_render_cta(\OpsIQ\Kb\HcCta::words((array)($homeCta ?? []), $cfg));

    [$homeCtaVariant, $homeCtaSize, $homeCtaWidth, $homeCtaAlign,
     $homeCtaRadius, $homeCtaShadow, $homeCtaHeight, $homeCtaSides] = $prev;

    if ($html === '') return '';

    /* The wrapper carries the surface, the slot and the operator's own classes,
     * so a stylesheet can reach "the band on article pages" without guessing,
     * and the responsive setting is a class rather than a second render. */
    $cls = 'hc-cta-slot hc-cta-s-' . hc_esc($surface) . ' hc-cta-at-' . hc_esc(str_replace('_', '-', $slot));
    $show = (string)($cfg['show'] ?? '');
    if ($show === 'desktop' || $show === 'mobile') $cls .= ' hc-cta-only-' . $show;
    if (($cfg['cls'] ?? '') !== '') $cls .= ' ' . hc_esc($cfg['cls']);
    return '<div class="' . $cls . '">' . $html . '</div>';
}

/* Convenience for the many call sites that just echo. */
function hc_cta_at(string $surface, string $slot): void { echo hc_cta_slot($surface, $slot); }

/**
 * "After N items" and "between items" for the listing surfaces. Given the index
 * of the item just rendered, returns the band when it belongs right there.
 * $total lets "after N" fall back to the end of a short list instead of never
 * rendering: an operator who says "after 8 articles" on a 5-article page still
 * means "show it".
 */
function hc_cta_between(string $surface, int $index, int $total, string $everySlot = ''): string {
    $surfaces = $GLOBALS['__hcCtaSurfaces'] ?? null;
    if (!is_array($surfaces) || !isset($surfaces[$surface])) return '';
    $cfg = $surfaces[$surface];
    if (($cfg['on'] ?? '') !== '1') return '';

    $place = (string)($cfg['place'] ?? '');
    if ($place === 'after_n') {
        $n = max(0, (int)($cfg['n'] ?? 0));
        $at = ($n <= 0 || $n > $total) ? $total : $n;       /* short list: the end */
        return ($index + 1) === $at ? hc_cta_slot_forced($surface, 'after_n') : '';
    }
    if ($everySlot !== '' && $place === $everySlot) {
        /* "Between" = one band, in the middle, not one after every item. */
        $mid = (int)floor($total / 2);
        if ($mid < 1) $mid = $total;
        return ($index + 1) === $mid ? hc_cta_slot_forced($surface, $everySlot) : '';
    }
    return '';
}

/* The placement test has already passed in hc_cta_between(), so this renders
 * without re-checking it. Kept separate so hc_cta_slot() stays the single
 * public entry point with the single guard. */
function hc_cta_slot_forced(string $surface, string $slot): string {
    $surfaces = $GLOBALS['__hcCtaSurfaces'] ?? null;
    if (!is_array($surfaces)) return '';
    $keep = $surfaces[$surface]['place'] ?? '';
    $GLOBALS['__hcCtaSurfaces'][$surface]['place'] = $slot;
    $html = hc_cta_slot($surface, $slot);
    $GLOBALS['__hcCtaSurfaces'][$surface]['place'] = $keep;
    return $html;
}

/**
 * The article body placements: after the first paragraph ("intro"), after the
 * Nth paragraph, or after N% of the article. Splitting on the closing tag of a
 * top-level paragraph is the only split that lands between blocks rather than
 * inside a sentence; when the body has no paragraphs at all (a table, a list,
 * one long div) nothing is injected and the band falls to its next chance.
 */
function hc_cta_inject_body(string $html, string $surface = 'article'): string {
    $surfaces = $GLOBALS['__hcCtaSurfaces'] ?? null;
    if (!is_array($surfaces) || !isset($surfaces[$surface])) return $html;
    $cfg = $surfaces[$surface];
    if (($cfg['on'] ?? '') !== '1') return $html;

    $place = (string)($cfg['place'] ?? '');
    if (!in_array($place, ['after_intro', 'after_paragraph', 'after_percent'], true)) return $html;

    $parts = preg_split('~(?<=</p>)~i', $html);
    if (!is_array($parts)) return $html;
    $parts = array_values(array_filter($parts, static fn ($p) => trim((string)$p) !== ''));
    $count = count($parts);
    if ($count < 2) return $html;

    if ($place === 'after_intro')          $at = 1;
    elseif ($place === 'after_paragraph')  $at = max(1, min($count - 1, (int)($cfg['n'] ?? 1)));
    else                                   $at = max(1, min($count - 1, (int)round($count * ((int)($cfg['pct'] ?? 50) / 100))));

    $band = hc_cta_slot_forced($surface, $place);
    if ($band === '') return $html;

    array_splice($parts, $at, 0, [$band]);
    return implode('', $parts);
}

function hc_render_quick(array $items, array $tabArticles = []): string {
    global $homeQuickTitle, $homeQuickStyle, $_layout;
    if (!$items) return '';
    $style = (string)($homeQuickStyle ?? 'tiles');
    if (!in_array($style, ['tabs', 'tiles', 'rows', 'inline', 'numbered', 'columns', 'marquee', 'split'], true)) $style = 'tiles';
    $uid   = 'hcq' . substr(md5(implode('|', array_column($items, 'label'))), 0, 6);
    ob_start(); ?>
      <section class="hc-quick-shell hc-quick-<?= hc_esc($style) ?>">
        <?php if (($homeQuickTitle ?? '') !== ''): ?>
        <div class="hc-sec hc-sec-<?= hc_esc($_layout) ?>"><span class="hc-sec-label hc-label-quick"><?= hc_esc($homeQuickTitle) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>
        <?php endif; ?>
        <?php if ($style === 'tabs'): ?>
        <?php /* THE RAIL AND THE PANELS, as the enterprise block had them: a rail of
                 named tabs on one side, their panels on the other, one label shared
                 between each pair. The panels are <details>, so they are INDEPENDENT
                 — opening one never closes another and the reader can have all of
                 them open at once. The first ships open so the section never arrives
                 as a stack of closed bars. A tab links to its panel by id, which is
                 what makes it work with scripting off. */ ?>
        <?php /* A tab whose source yielded nothing is dropped WITH its panel, so
                 the "first" one that opens is the first SURVIVOR — not index 0,
                 which may not be on the page at all. */
          $live = [];
          foreach ($items as $i => $it) { if (isset($tabArticles[$i])) $live[] = $i; }
          $firstLive = $live ? $live[0] : -1; ?>
        <div class="hce-tabs" data-hce-tabs>
          <div class="hce-tabs-grid"><div class="hce-tabs-rail">
            <?php foreach ($items as $i => $it):
              if (!isset($tabArticles[$i])) continue;
              /* An empty icon is not "no icon", it is "choose one for me": derived
                 from the CURRENT label every render and never stored, so renaming
                 a tab re-picks its icon and the two cannot drift apart. */
              $icon = trim((string)($it['icon'] ?? ''));
              if ($icon === '') $icon = hc_quick_auto_icon((string)$it['label']); ?>
            <a class="hce-tab<?= $i === $firstLive ? ' is-on' : '' ?>" href="#<?= hc_esc($uid . '-p' . $i) ?>" data-hce-tab="<?= (int)$i ?>">
              <?php if ($icon !== ''): ?><span class="hce-tab-ico" aria-hidden="true"><?= hc_cat_icon_html($icon) ?></span><?php endif; ?>
              <span class="hce-tab-label"><?= hc_esc((string)$it['label']) ?></span>
            </a>
            <?php endforeach; ?>
          </div>
          <div class="hce-tabs-panels hce-topics">
            <?php foreach ($items as $i => $it):
              if (!isset($tabArticles[$i])) continue;
              $rows = (array)$tabArticles[$i]; ?>
            <details class="hce-topic hce-tabpanel" id="<?= hc_esc($uid . '-p' . $i) ?>" data-hce-panel="<?= (int)$i ?>"<?= $i === $firstLive ? ' open' : '' ?>>
              <summary class="hce-topic-head">
                <span class="hce-topic-title"><?= hc_esc((string)$it['label']) ?></span>
                <span class="hce-topic-chev" aria-hidden="true"></span>
              </summary>
              <div class="hce-topic-body">
                <?php if (!$rows): ?>
                <div class="hce-topic-empty"><?= hc_esc($GLOBALS['__t']('quick_tab_empty', 'Nothing matches this name yet.')) ?></div>
                <?php endif; ?>
                <?php foreach ($rows as $grp):
                  $gLabel = trim((string)($grp['label'] ?? ''));
                  $gItems = (array)($grp['items'] ?? []);
                  if (!$gItems) continue;
                  if ($gLabel !== ''): ?><div class="hce-topic-group"><?= hc_esc($gLabel) ?></div><?php endif; ?>
                <ul class="hce-topic-list" role="list">
                  <?php foreach ($gItems as $r): $sl = (string)($r['slug'] ?? ''); if ($sl === '') continue; ?>
                  <?php /* The PLAIN article url. A tab is a grouping the operator
                           invented, not a place — carrying it in the link would put
                           the tab's name into that article's breadcrumb, where the
                           reader expects the article's own category path. */ ?>
                  <li><a class="hce-topic-link" href="<?= hc_esc(hc_u('article=' . urlencode($sl))) ?>"><?= hc_esc((string)($r['page_title'] ?? '')) ?></a></li>
                  <?php endforeach; ?>
                </ul>
                <?php endforeach; ?>
              </div>
            </details>
            <?php endforeach; ?>
          </div></div>
        </div>
        <?php elseif ($style === 'rows'): ?>
        <?php /* Full-width rows. A list, not a grid: the label and its note read on one
                 line and the eye runs straight down the left edge. */ ?>
        <ul class="hc-qrows" role="list">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? '')); ?>
          <li>
            <?php if ($u !== ''): ?><a href="<?= hc_esc($u) ?>"><?php else: ?><span><?php endif; ?>
              <span class="hc-qr-label"><?= hc_esc((string)$it['label']) ?></span>
              <?php if (($it['desc'] ?? '') !== ''): ?><span class="hc-qr-desc"><?= hc_esc((string)$it['desc']) ?></span><?php endif; ?>
              <svg class="hc-qr-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
            <?php if ($u !== ''): ?></a><?php else: ?></span><?php endif; ?>
          </li>
          <?php endforeach; ?>
        </ul>

        <?php elseif ($style === 'inline'): ?>
        <?php /* ONE paragraph. Descriptions are dropped on purpose — the whole point of
                 this presentation is that the block takes a single line. The separator is
                 a pseudo-element so it is never selected, copied, or read aloud. */ ?>
        <p class="hc-qinline">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? '')); ?>
            <?php if ($u !== ''): ?><a href="<?= hc_esc($u) ?>"><?= hc_esc((string)$it['label']) ?></a><?php else: ?><span><?= hc_esc((string)$it['label']) ?></span><?php endif; ?>
          <?php endforeach; ?>
        </p>

        <?php elseif ($style === 'numbered'): ?>
        <?php /* An <ol>, so the order is in the DOCUMENT and not only in the CSS counter —
                 a screen reader announces "1 of 6" without the numerals being read as text. */ ?>
        <ol class="hc-qnum">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? '')); ?>
          <li>
            <?php if ($u !== ''): ?><a href="<?= hc_esc($u) ?>"><?php else: ?><span><?php endif; ?>
              <span class="hc-qn-label"><?= hc_esc((string)$it['label']) ?></span>
              <?php if (($it['desc'] ?? '') !== ''): ?><span class="hc-qn-desc"><?= hc_esc((string)$it['desc']) ?></span><?php endif; ?>
            <?php if ($u !== ''): ?></a><?php else: ?></span><?php endif; ?>
          </li>
          <?php endforeach; ?>
        </ol>

        <?php elseif ($style === 'columns'): ?>
        <?php /* A sitemap column list. No box, no icon, no note — just the names, flowing. */ ?>
        <ul class="hc-qcols" role="list">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? '')); ?>
          <li><?php if ($u !== ''): ?><a href="<?= hc_esc($u) ?>"><?= hc_esc((string)$it['label']) ?></a><?php else: ?><span><?= hc_esc((string)$it['label']) ?></span><?php endif; ?></li>
          <?php endforeach; ?>
        </ul>

        <?php elseif ($style === 'marquee'): ?>
        <?php /* Oversized lines with a rule between, matching the category marquee so a
                 page using both reads as one design rather than two. */ ?>
        <div class="hc-qmarquee" role="list">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? ''));
            $tag = $u !== '' ? 'a' : 'div'; ?>
          <<?= $tag ?> class="hc-qm-line" role="listitem"<?= $u !== '' ? ' href="' . hc_esc($u) . '"' : '' ?>>
            <span class="hc-qm-label"><?= hc_esc((string)$it['label']) ?></span>
            <?php if (($it['desc'] ?? '') !== ''): ?><span class="hc-qm-desc"><?= hc_esc((string)$it['desc']) ?></span><?php endif; ?>
          </<?= $tag ?>>
          <?php endforeach; ?>
        </div>

        <?php elseif ($style === 'split'): ?>
        <?php /* The first item is promoted; the rest stack beside it. Two containers, so
                 the promotion is structural and not a class on an otherwise equal tile. */
          $lead = $items[0] ?? null;
          $rest = array_slice($items, 1); ?>
        <div class="hc-qsplit">
          <?php if ($lead):
            $lu = trim((string)($lead['url'] ?? ''));
            $lt = $lu !== '' ? 'a' : 'div'; ?>
          <<?= $lt ?> class="hc-qs-lead"<?= $lu !== '' ? ' href="' . hc_esc($lu) . '"' : '' ?>>
            <?php if (($lead['icon'] ?? '') !== ''): ?><span class="hc-qs-ico" aria-hidden="true"><?= hc_cat_icon_html((string)$lead['icon']) ?></span><?php endif; ?>
            <span class="hc-qs-label"><?= hc_esc((string)$lead['label']) ?></span>
            <?php if (($lead['desc'] ?? '') !== ''): ?><span class="hc-qs-desc"><?= hc_esc((string)$lead['desc']) ?></span><?php endif; ?>
          </<?= $lt ?>>
          <?php endif; ?>
          <?php if ($rest): ?>
          <ul class="hc-qs-rest" role="list">
            <?php foreach ($rest as $it):
              $u = trim((string)($it['url'] ?? '')); ?>
            <li><?php if ($u !== ''): ?><a href="<?= hc_esc($u) ?>"><?= hc_esc((string)$it['label']) ?></a><?php else: ?><span><?= hc_esc((string)$it['label']) ?></span><?php endif; ?></li>
            <?php endforeach; ?>
          </ul>
          <?php endif; ?>
        </div>

        <?php else: ?>
        <div class="hc-quick-grid" role="list">
          <?php foreach ($items as $it):
            $u = trim((string)($it['url'] ?? ''));
            $tag = $u !== '' ? 'a' : 'div'; ?>
          <<?= $tag ?> class="hc-quick-tile" role="listitem"<?= $u !== '' ? ' href="' . hc_esc($u) . '"' : '' ?>>
            <?php if (($it['icon'] ?? '') !== ''): ?><span class="hc-quick-ico" aria-hidden="true"><?= hc_cat_icon_html((string)$it["icon"]) ?></span><?php endif; ?>
            <span class="hc-quick-copy">
              <span class="hc-quick-label"><?= hc_esc((string)$it['label']) ?></span>
              <?php if (($it['desc'] ?? '') !== ''): ?><span class="hc-quick-desc"><?= hc_esc((string)$it['desc']) ?></span><?php endif; ?>
            </span>
          </<?= $tag ?>>
          <?php endforeach; ?>
        </div>
        <?php endif; ?>
      </section>
    <?php return (string)ob_get_clean();
}

/* ══════════════════════════════════════════════════════════════════════════
   PHASE8_2026-08-08 — THE STANDALONE QUICK-LINKS MODULE.

   Fifteen presentations. Each is a different STRUCTURE — different element tree, not the
   same markup with another class — because the 08-07 lesson was that three names on
   one enum, folded back to two by the renderer, is not three variants.

   All fifteen consume ONE resolved list (HcLinks::resolve), so a presentation cannot be
   built that renders a shell with no articles in it. That was the exact failure the
   owner caught.
   ══════════════════════════════════════════════════════════════════════════ */
function hc_links_block(): string
{
    $cfg = isset($GLOBALS['_settings']) && is_array($GLOBALS['_settings']) ? $GLOBALS['_settings'] : [];
    $on  = !in_array(strtolower(trim((string)($cfg['hc_links_enabled'] ?? ''))), ['', '0', 'off', 'false', 'no'], true);
    if (!$on) return '';

    $siteKey = (string)($GLOBALS['_siteKey'] ?? '');
    if ($siteKey === '' || !class_exists('\\OpsIQ\\Kb\\HcLinks')) return '';

    $type   = strtolower(trim((string)($cfg['hc_links_type'] ?? 'grid')));
    if (!\OpsIQ\Kb\HcLinks::isType($type)) $type = 'grid';
    $source = strtolower(trim((string)($cfg['hc_links_source'] ?? 'categories')));
    if (!\OpsIQ\Kb\HcLinks::isSource($source)) $source = 'categories';

    $limit  = max(1, min(24, (int)($cfg['hc_links_limit'] ?? 8)));
    $cols   = max(2, min(5,  (int)($cfg['hc_links_columns'] ?? 3)));
    $counts = !in_array(strtolower(trim((string)($cfg['hc_links_show_counts'] ?? '1'))), ['', '0', 'off', 'false', 'no'], true);
    $descs  = !in_array(strtolower(trim((string)($cfg['hc_links_show_desc'] ?? ''))), ['', '0', 'off', 'false', 'no'], true);
    $openF  = !in_array(strtolower(trim((string)($cfg['hc_links_open_first'] ?? '1'))), ['', '0', 'off', 'false', 'no'], true);

    $items = \OpsIQ\Kb\HcLinks::resolve($siteKey, $source, $limit, (string)($cfg['hc_links_items'] ?? ''));
    if (!$items) return '';

    $t     = $GLOBALS['__t'] ?? static fn($k, $d) => $d;
    $title = trim((string)($cfg['hc_links_title'] ?? ''));
    if ($title === '') $title = $t('links_title', 'Browse');

    $u = static function (string $url): string {
        if ($url === '' || preg_match('~^(https?:|mailto:|#)~i', $url)) return $url;
        return function_exists('hc_u') ? hc_u(ltrim($url, '?')) : $url;
    };

    ob_start(); ?>
<?php /* PHASE0.5_2026-08-09 — `hc-links-v-<type>`, not `hc-links-<type>`.
         The section used to take the bare type name as its modifier, and five of the original ten
         types name their INNER list container the same thing (`hc-links-grid`,
         `-strip`, `-chips`, `-cards`, `-stack`). The layout rule then matched BOTH, so the
         grid nested inside itself: the section laid out three 352px tracks and the inner
         div laid out three 109px tracks inside the first one. Tiles rendered at a ninth of
         their width and the whole block occupied a third of the row.
         Found by looking at the page, not by counting elements — the Phase 8 check
         measured "8 links, class hc-links-tile" and passed, because element counts cannot
         see a nested grid. Same `-v-` variant prefix the directory cards already use. */ ?>
<section class="hc-links hc-links-v-<?= hc_esc($type) ?>" style="--hcl-cols:<?= (int)$cols ?>">
  <?php /* PHASE10K7_2026-08-11 — this heading was the ONLY band label rendered
           without a modifier class, so no size rule could ever reach it. That is
           why the operator could not find "Popular right now" in Typography: the
           control had nothing to target. */ ?>
  <div class="hc-sec"><span class="hc-sec-label hc-label-links"><?= hc_esc($title) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>

<?php if ($type === 'grid'): ?>
  <div class="hc-links-grid" role="list">
    <?php foreach ($items as $it): ?>
    <a class="hc-links-tile" role="listitem" href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($descs && $it['desc'] !== ''): ?><span class="hc-links-desc"><?= hc_esc($it['desc']) ?></span><?php endif; ?>
      <?php if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?>
    </a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === 'list'): ?>
  <ul class="hc-links-list" role="list">
    <?php foreach ($items as $it): ?>
    <li><a href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($descs && $it['desc'] !== ''): ?><span class="hc-links-desc"><?= hc_esc($it['desc']) ?></span><?php endif; ?>
      <?php if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?>
    </a></li>
    <?php endforeach; ?>
  </ul>

<?php elseif ($type === 'columns'): ?>
  <?php /* A sitemap-style index: one flowing list broken into columns by CSS, so the
           reading order stays top-to-bottom within each column. */ ?>
  <ul class="hc-links-cols" role="list">
    <?php foreach ($items as $it): ?>
    <li><a href="<?= hc_esc($u($it['url'])) ?>"><?= hc_esc($it['label']) ?><?php
      if ($counts && $it['count'] > 0): ?> <span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?></a></li>
    <?php endforeach; ?>
  </ul>

<?php elseif ($type === 'strip'): ?>
  <?php /* Horizontal scroll. Keyboard reachable because each card is a link. */ ?>
  <div class="hc-links-strip" role="list" tabindex="0" aria-label="<?= hc_esc($title) ?>">
    <?php foreach ($items as $it): ?>
    <a class="hc-links-scard" role="listitem" href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?>
    </a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === 'accordion'): ?>
  <?php /* <details> so it works with JS off, and the first may ship open so the block
           never arrives as a stack of closed bars. */ ?>
  <div class="hc-links-acc">
    <?php foreach ($items as $i => $it): ?>
    <details class="hc-links-item"<?= ($openF && $i === 0) ? ' open' : '' ?>>
      <summary><?= hc_esc($it['label']) ?><?php if ($counts && $it['count'] > 0): ?> <span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?></summary>
      <div class="hc-links-acc-body">
        <?php if ($it['desc'] !== ''): ?><p class="hc-links-desc"><?= hc_esc($it['desc']) ?></p><?php endif; ?>
        <a class="hc-links-go" href="<?= hc_esc($u($it['url'])) ?>"><?= hc_esc($t('links_open', 'Open')) ?></a>
      </div>
    </details>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === 'spotlight'): ?>
  <?php $lead = array_shift($items); ?>
  <div class="hc-links-spot">
    <a class="hc-links-lead" href="<?= hc_esc($u($lead['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($lead['label']) ?></span>
      <?php if ($lead['desc'] !== ''): ?><span class="hc-links-desc"><?= hc_esc($lead['desc']) ?></span><?php endif; ?>
      <?php if ($counts && $lead['count'] > 0): ?><span class="hc-links-count"><?= (int)$lead['count'] ?></span><?php endif; ?>
    </a>
    <div class="hc-links-rest" role="list">
      <?php foreach ($items as $it): ?>
      <a class="hc-links-small" role="listitem" href="<?= hc_esc($u($it['url'])) ?>"><?= hc_esc($it['label']) ?></a>
      <?php endforeach; ?>
    </div>
  </div>

<?php elseif ($type === 'numbered'): ?>
  <ol class="hc-links-num">
    <?php foreach ($items as $it): ?>
    <li><a href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?>
    </a></li>
    <?php endforeach; ?>
  </ol>

<?php elseif ($type === 'chips'): ?>
  <div class="hc-links-chips" role="list">
    <?php foreach ($items as $it): ?>
    <a class="hc-links-chip" role="listitem" href="<?= hc_esc($u($it['url'])) ?>"><?= hc_esc($it['label']) ?><?php
      if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?></a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === 'cards'): ?>
  <div class="hc-links-cards" role="list">
    <?php foreach ($items as $it): ?>
    <a class="hc-links-card" role="listitem" href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($it['desc'] !== ''): ?><span class="hc-links-desc"><?= hc_esc($it['desc']) ?></span><?php endif; ?>
      <span class="hc-links-go"><?= hc_esc($t('links_open', 'Open')) ?></span>
    </a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === "bento"): ?>
  <?php /* Asymmetric board: the first route is the orientation point, followed by
           alternating wide and compact discoveries rather than equal cards. */ ?>
  <div class="hc-links-bento" role="list">
    <?php foreach ($items as $i => $it): ?>
    <a class="hc-links-bento-item<?= $i === 0 ? " is-lead" : (($i % 4) === 1 ? " is-wide" : "") ?>" role="listitem" href="<?= hc_esc($u($it["url"])) ?>">
      <span class="hc-links-bento-index" aria-hidden="true"><?= str_pad((string)($i + 1), 2, "0", STR_PAD_LEFT) ?></span>
      <?php if ($it["icon"] !== ""): ?><span class="hc-links-bento-icon" aria-hidden="true"><?= hc_cat_icon_html((string)$it["icon"]) ?></span><?php endif; ?>
      <span class="hc-links-name"><?= hc_esc($it["label"]) ?></span>
      <?php if ($descs && $it["desc"] !== ""): ?><span class="hc-links-desc"><?= hc_esc($it["desc"]) ?></span><?php endif; ?>
      <?php if ($counts && $it["count"] > 0): ?><span class="hc-links-count"><?= (int)$it["count"] ?></span><?php endif; ?>
      <svg class="hc-links-arrow" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
    </a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === "command"): ?>
  <?php /* A keyboard-like command board: one dense scan column, explicit route
           numbers and descriptions, with no decorative card grid. */ ?>
  <div class="hc-links-command" role="list" aria-label="<?= hc_esc($title) ?>">
    <div class="hc-links-command-bar" aria-hidden="true"><i></i><i></i><i></i><span>/ <?= hc_esc($title) ?></span></div>
    <?php foreach ($items as $i => $it): ?>
    <a class="hc-links-command-row" role="listitem" href="<?= hc_esc($u($it["url"])) ?>">
      <span class="hc-links-command-key"><?= str_pad((string)($i + 1), 2, "0", STR_PAD_LEFT) ?></span>
      <span class="hc-links-command-copy"><span class="hc-links-name"><?= hc_esc($it["label"]) ?></span><?php if ($descs && $it["desc"] !== ""): ?><span class="hc-links-desc"><?= hc_esc($it["desc"]) ?></span><?php endif; ?></span>
      <?php if ($counts && $it["count"] > 0): ?><span class="hc-links-count"><?= (int)$it["count"] ?></span><?php endif; ?>
      <svg class="hc-links-arrow" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
    </a>
    <?php endforeach; ?>
  </div>

<?php elseif ($type === "launch"): ?>
  <?php /* A sequenced deck for onboarding or rollout paths. Each stop is a step,
           so the relationship reads left-to-right instead of as independent cards. */ ?>
  <ol class="hc-links-launch">
    <?php foreach ($items as $i => $it): ?>
    <li><a href="<?= hc_esc($u($it["url"])) ?>">
      <span class="hc-links-launch-step"><?= str_pad((string)($i + 1), 2, "0", STR_PAD_LEFT) ?></span>
      <span class="hc-links-launch-copy"><span class="hc-links-name"><?= hc_esc($it["label"]) ?></span><?php if ($descs && $it["desc"] !== ""): ?><span class="hc-links-desc"><?= hc_esc($it["desc"]) ?></span><?php endif; ?></span>
      <?php if ($counts && $it["count"] > 0): ?><span class="hc-links-count"><?= (int)$it["count"] ?></span><?php endif; ?>
    </a></li>
    <?php endforeach; ?>
  </ol>

<?php elseif ($type === "editorial"): ?>
  <?php /* One authored lead and a compact reading index: a magazine hierarchy,
           not the equal-weight utility grid used by the card presentations. */ ?>
  <?php $lead = array_shift($items); ?>
  <div class="hc-links-editorial">
    <a class="hc-links-editorial-lead" href="<?= hc_esc($u($lead["url"])) ?>">
      <span class="hc-links-editorial-mark" aria-hidden="true">01</span>
      <span class="hc-links-name"><?= hc_esc($lead["label"]) ?></span>
      <?php if ($descs && $lead["desc"] !== ""): ?><span class="hc-links-desc"><?= hc_esc($lead["desc"]) ?></span><?php endif; ?>
      <?php if ($counts && $lead["count"] > 0): ?><span class="hc-links-count"><?= (int)$lead["count"] ?></span><?php endif; ?>
      <span class="hc-links-go"><?= hc_esc($t("links_open", "Open")) ?></span>
    </a>
    <ol class="hc-links-editorial-index" start="2">
      <?php foreach ($items as $it): ?>
      <li><a href="<?= hc_esc($u($it["url"])) ?>"><span class="hc-links-name"><?= hc_esc($it["label"]) ?></span><?php if ($counts && $it["count"] > 0): ?><span class="hc-links-count"><?= (int)$it["count"] ?></span><?php endif; ?><svg class="hc-links-arrow" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></li>
      <?php endforeach; ?>
    </ol>
  </div>

<?php elseif ($type === "timeline"): ?>
  <?php /* A guided vertical progression with a continuous rail. Unlike numbered,
           the line makes sequence and continuation the primary information. */ ?>
  <ol class="hc-links-timeline">
    <?php foreach ($items as $i => $it): ?>
    <li><span class="hc-links-timeline-node" aria-hidden="true"><?= str_pad((string)($i + 1), 2, "0", STR_PAD_LEFT) ?></span><a href="<?= hc_esc($u($it["url"])) ?>">
      <span class="hc-links-name"><?= hc_esc($it["label"]) ?></span>
      <?php if ($descs && $it["desc"] !== ""): ?><span class="hc-links-desc"><?= hc_esc($it["desc"]) ?></span><?php endif; ?>
      <?php if ($counts && $it["count"] > 0): ?><span class="hc-links-count"><?= (int)$it["count"] ?></span><?php endif; ?>
    </a></li>
    <?php endforeach; ?>
  </ol>

<?php elseif ($type === "stack"): ?>
  <?php /* Staggered: a CSS column flow, so cards of different heights interlock
           instead of leaving a ragged grid row. */ ?>
  <div class="hc-links-stack">
    <?php foreach ($items as $it): ?>
    <a class="hc-links-scell" href="<?= hc_esc($u($it['url'])) ?>">
      <span class="hc-links-name"><?= hc_esc($it['label']) ?></span>
      <?php if ($descs && $it['desc'] !== ''): ?><span class="hc-links-desc"><?= hc_esc($it['desc']) ?></span><?php endif; ?>
      <?php if ($counts && $it['count'] > 0): ?><span class="hc-links-count"><?= (int)$it['count'] ?></span><?php endif; ?>
    </a>
    <?php endforeach; ?>
  </div>
<?php endif; ?>
</section>
<?php
    return (string) ob_get_clean();
}

function hc_render_home(array $cats, array $articles, array $featured = []): string {
    global $__t, $_layout, $showPopular, $txtPopularLabel, $articleCardStyle, $homeLayout, $homeCategoryIcons, $txtBrowseLabel, $showStats, $_settings, $_allArticles;
    /* PHASE_HC_CAT_VISIBLE_2026-08-15 — the home Categories switch did nothing.
     * The Studio renders a real visibility control for `home_categories`, it saves into
     * section_rules, and HcSections resolves it — but hc_render_home() never imported the
     * result, gating the block on `home_layout` alone. So an operator switched the section
     * off, the save reported success, and the tiles kept rendering. HcSections::apply()
     * deliberately ignores `visible` (it filters ITEMS, not sections), which is why the
     * section's own renderer has to honour it. */
    global $showCategories;
    global $homeFeaturedEnabled, $homeFeaturedTitle, $homeFeaturedPosition, $homeCategoriesTitle;
    $layout = $_layout;
    $cardStyle = $articleCardStyle ?: 'list';

    /* PHASE_HC_HOME_FEATURED — the featured (★) block. Rendered around the main
     * content (category tiles / article list) per home_featured_position, so a home
     * can show categories AND a titled row of promoted articles at once. */
    $featHtml = '';
    if (!empty($homeFeaturedEnabled) && $featured) {
        ob_start(); ?>
      <section class="hc-feat-shell hc-pop-shell hc-pop-shell-<?= hc_esc($layout) ?>">
        <div class="hc-sec hc-sec-<?= hc_esc($layout) ?>"><span class="hc-sec-label hc-label-featured"><?= hc_esc($homeFeaturedTitle) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>
        <div class="hc-pop-list hc-pop-list-<?= hc_esc($layout) ?> hc-cards-<?= hc_esc($cardStyle) ?>" role="list"><?php foreach (array_values($featured) as $i=>$art) echo hc_pop_card($art,$i,$layout); ?></div>
      </section>
        <?php $featHtml = (string)ob_get_clean();
    }

    ob_start();
    /* PHASE_HC_HOME_LAYOUT — categories mode: the home page IS the category
     * index. Five polished tile styles (home_category_style); no side-nav. */
    if (($homeLayout ?? 'categories') === 'categories' && ($showCategories ?? true)):
        global $homeCategoryStyle, $homeCategoryLimit;
        $catStyle = in_array(($homeCategoryStyle ?? 'card'), ['card','badge','minimal','glow','list','bare','plain','detail'], true) ? $homeCategoryStyle : 'card';
        $tileCats = array_values(array_filter($cats, function($c){ return (int)($c['id'] ?? 0) > 0; }));
        /* PHASE3_2026-08-06 — nesting. `flat` is the behaviour every existing site
         * runs on: every category is a tile, so a child sits beside its own parent.
         * `top` and `nested` drop children from the top level; nested then lists them
         * inside their parent on the directory-shaped presentations.
         *
         * A child whose PARENT IS NOT IN THIS SET must stay visible. The home rule can
         * select a subset (latest, featured, a custom list), and hiding a child whose
         * parent was filtered out would make it unreachable from the home page
         * entirely — a silent hole rather than a tidier grid. */
        $__hier = (string)($_settings['cat_hierarchy'] ?? 'flat');
        $__childrenOf = [];
        if ($__hier === 'top' || $__hier === 'nested') {
            $__present = [];
            foreach ($tileCats as $__c) $__present[(int)($__c['id'] ?? 0)] = true;
            $__kept = [];
            foreach ($tileCats as $__c) {
                $__pid = (int)($__c['parent_id'] ?? 0);
                if ($__pid > 0 && isset($__present[$__pid])) { $__childrenOf[$__pid][] = $__c; continue; }
                $__kept[] = $__c;
            }
            $tileCats = $__kept;
        }
        /* PHASE_HC_SECTIONS — the home_categories rule decides which tiles show
         * and in what order (all / a set number / latest / featured / custom).
         *
         * A limit must NEVER be a dead end. Every category is still rendered and
         * linked (crawlers and the sitemap see the full set); the overflow is
         * only hidden with CSS, behind a "Show all N categories" control. That
         * control is a real link to ?cats=all, so it works with JavaScript off;
         * with JS on it expands the grid in place instead of reloading. */
        /* Render EVERY tile the rule selects (its sort + featured/tag filter
         * applied), and CSS-hide the overflow past the cap. This is what lets the
         * "Show all" button reveal the rest IN PLACE — the hidden tiles are already
         * in the DOM. An earlier refactor sliced the list down to the cap here,
         * which left nothing for the button to reveal (a dead control). We apply
         * the rule with the cap REMOVED (limit=0), keep the full result, and pass
         * the cap to hc_cat_tiles as the CSS hide-point. */
        $__ruleFull = array_merge(hc_rule('home_categories'), ['limit' => 0]);
        /* PHASE10K15_2026-08-12 — SHOW ALL MEANS THE REST.
         *
         * The owner: "show all mean showing th rest. include mans what will bee
         * listed in th hom page, not hiding everything."
         *
         * So the home rule curates the HOME LIST — mode, the Only-these list, a
         * tag — while the set the link opens is the catalogue. Before this, the
         * link was tied to the COUNT overflowing the curated list, so a workspace
         * listing six chosen categories out of ninety-seven had no way to reach
         * the other ninety-one and no control on the page saying so.
         *
         * EXCLUDE is the one filter that survives into the rest: naming a category
         * to hide means hide it, everywhere. Include never hides anything — it
         * only says what the home page leads with. */
        $__ruleRest = array_merge($__ruleFull, ['mode' => 'all', 'include' => [], 'tag' => '', 'pick' => []]);
        $__restCats = $tileCats;
        if (class_exists('\\OpsIQ\\Kb\\HcSections')) {
            $__restCats = \OpsIQ\Kb\HcSections::apply($tileCats, $__ruleRest, 'categories')['items'];
            $tileCats   = \OpsIQ\Kb\HcSections::apply($tileCats, $__ruleFull, 'categories')['items'];
        }
        $__cfgLimit  = max(0, (int)(hc_rule('home_categories')['limit'] ?? 0));
        $catTotal    = count($tileCats);
        $__restTotal = count($__restCats);
        $showAll     = (isset($_GET['cats']) && $_GET['cats'] === 'all');
        /* Following the link has to actually reveal the rest, so at that point the
         * curated list is replaced by the full one. */
        if ($showAll) { $tileCats = $__restCats; $catTotal = $__restTotal; }
        /* PHASE9e_2026-08-10 — the browse page does NOT show everything either: 111 cards
         * in one response is a slow page wherever it is served. `n` is the running total,
         * clamped, and it is a real query parameter so "show more" works with JS off and
         * the position survives a refresh or a shared link. */
        $browseStep = max(6, min(200, (int)($_settings['cats_page_limit'] ?? 24)));
        $browseShown = 0;
        if (!empty($GLOBALS['_isBrowse'])) {
            $browseShown = (int)($_GET['n'] ?? 0);
            if ($browseShown < $browseStep) $browseShown = $browseStep;
            $browseShown = min($browseShown, 500);
        }
        /* PHASE9e_2026-08-10 — 'page' turns ?cats=all into a BROWSE SURFACE rather than an
         * expanded home page. The link is real either way, so this works with JS off; the
         * only difference is whether the client is allowed to expand in place instead of
         * navigating (data-hc-more, further down). */
        $catsAction = strtolower(trim((string)($_settings['cats_more_action'] ?? 'page')));
        if (!in_array($catsAction, ['page', 'expand', 'paginate'], true)) $catsAction = 'page';
        $catsStyle  = strtolower(trim((string)($_settings['cats_more_style'] ?? 'pill')));
        if (!in_array($catsStyle, ['pill', 'arrow', 'bar', 'ghost', 'minimal'], true)) $catsStyle = 'pill';
        $catsRadius = strtolower(trim((string)($_settings['cats_more_radius'] ?? 'pill')));
        if (!in_array($catsRadius, ['pill', 'round', 'soft', 'sharp'], true)) $catsRadius = 'pill';
        /* Capped only when a real limit hides something AND the rule offers a
         * "show all" control. $limit is the CSS hide-point passed to hc_cat_tiles. */
        /* PHASE10K10_2026-08-11 — A COUNT IS A COUNT.
         *
         * The owner: "once you select exclude it just lists everything and
         * doesn't obey the count rule any more." The exclusion was innocent —
         * the cap was tied to the Show-all LINK: with "Offer a Show all link"
         * unticked, $capped was false and every tile rendered, so the count
         * silently did nothing. Their workspace has more=false and 62
         * categories, which is exactly what they were looking at.
         *
         * The limit now always applies. The link only decides HOW the overflow
         * is handled: with the link on, the extra tiles stay in the DOM and are
         * hidden so the button can reveal them in place; with it off, they are
         * not rendered at all. */
        $__moreLink = !empty(hc_rule('home_categories')['more']);
        /* The link appears when there is anything left to reach: either the count
         * is hiding part of the curated list, or the catalogue holds more than the
         * home page lists. The second case is the owner's: six chosen out of
         * ninety-seven hides nothing by count, and still leaves ninety-one to
         * reach. */
        $__shownNow = ($__cfgLimit > 0) ? min($catTotal, $__cfgLimit) : $catTotal;
        $__hasRest  = (!$showAll && $__restTotal > $__shownNow);
        /* PHASE_HC_CATS_PAGINATE_2026-08-14 — EXPAND AND PAGINATE NEED THE REST IN THE DOM.
         *
         * Both reveal in place, and neither can reveal what was never rendered. The extra
         * tiles were only emitted when the LIMIT hid part of the CURATED list; when the
         * rest lives OUTSIDE that list — six chosen out of a hundred and thirty-seven —
         * nothing was in the DOM, the paginator found one page and bailed, and the control
         * silently fell through to its href. That is the owner's *"the pagination selection
         * for show all is not working"*.
         *
         * The curated ones still lead, so the first page is exactly what the home shows
         * today; everything else follows behind the hide-point for the client to reveal. */
        if (!$showAll && $__hasRest && $__moreLink && in_array($catsAction, ['expand', 'paginate'], true)) {
            $__seen = [];
            foreach ($tileCats as $__c) $__seen[(int)($__c['id'] ?? 0)] = true;
            $__tail = [];
            foreach ($__restCats as $__c) {
                if (empty($__seen[(int)($__c['id'] ?? 0)])) $__tail[] = $__c;
            }
            if ($__tail) {
                /* the hide-point becomes what the home page shows today, so the page size
                 * the reader pages through is the one the operator chose */
                $__cfgLimit = max(1, $__shownNow);
                $tileCats   = array_merge($tileCats, $__tail);
                $catTotal   = count($tileCats);
            }
        }
        $capped     = (!$showAll && $__cfgLimit > 0 && $catTotal > $__cfgLimit);
        if ($capped && !$__moreLink) {
            /* No reveal control, so nothing to reveal: render the cap exactly. */
            $tileCats = array_slice($tileCats, 0, $__cfgLimit);
            $catTotal = count($tileCats);
        }
        $limit      = $__cfgLimit;
        /* PHASE9e_2026-08-10 — the browse surface caps at its OWN page size, not at the
         * home page's. The hide-point mechanism is reused exactly: every category is still
         * rendered and linked, so crawlers and the sitemap continue to see the full set —
         * a limit here must not become a dead end either. */
        if (!empty($GLOBALS['_isBrowse'])) {
            $limit  = $browseShown;
            $capped = ($catTotal > $browseShown);
        }
        if ($tileCats): ?>
      <section class="hc-home-cats-shell">
        <?php if (($homeCategoriesTitle ?? '') !== ''): ?>
        <div class="hc-sec hc-sec-<?= hc_esc($layout) ?>"><span class="hc-sec-label hc-label-cats"><?= hc_esc($homeCategoriesTitle) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>
        <?php endif; ?>
        <?php /* PHASE3_2026-08-06 — the presentation switch. `directory` is a different
                 STRUCTURE, so it replaces the tile grid outright rather than wrapping it.
                 Everything above (the rule, the cap, the ordering) already decided WHICH
                 categories show; only the rendering differs here. */
          /* PHASE9f_2026-08-10 — ONE SECTION, UP TO TWO PRESENTATIONS.
                   Lead with a few tiles and continue in soft cards, say. The nineteen
                   presentations were already interchangeable — each takes the same category
                   list and emits its own markup — so this splits the LIST, not the renderer.

                   THE CAP IS THE SECTION'S, NOT EACH HALF'S. The hide-point handed to the
                   second presentation is the limit minus whatever the first one took;
                   without that subtraction a limit of 6 with a split of 3 shows nine. */
            /* PHASE10K30_2026-08-13 — the section dispatcher moved into
             * hc_render_cat_presentations() so the category page's SUBCATEGORIES
             * can call exactly the same engine. Everything that used to live here
             * — up to three presentations, the two split points, the gaps between
             * blocks, the cap arithmetic — is in there now, keyed by prefix. */
            echo hc_render_cat_presentations(
                $tileCats, 'cat', $catStyle, !empty($homeCategoryIcons),
                $__childrenOf, $limit, $capped
            );
          ?>
        <?php if (!empty($GLOBALS['_isBrowse']) && $capped && $__moreLink):
          /* PHASE9e_2026-08-10 — "show more" on the browse page. A real link carrying the new
             running total, so it works with JavaScript off, survives a refresh, and can be
             shared or bookmarked at the position the reader reached. */
          $__nextN = min(500, $browseShown + $browseStep);
          $__left  = $catTotal - $browseShown; ?>
        <div class="hc-cats-more-wrap hc-cats-more-v-<?= hc_esc($catsStyle) ?> hc-cats-more-r-<?= hc_esc($catsRadius) ?>">
          <a class="hc-cats-more" href="<?= hc_esc(hc_u('cats=all&n=' . $__nextN)) ?>">
            <span class="hc-cats-more-label"><?= hc_esc($__t('show_more_cats','Show more categories')) ?></span>
            <span class="hc-cats-more-rest" aria-hidden="true">+<?= (int)$__left ?></span>
            <svg class="hc-cats-more-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M6 13l6 6 6-6"/></svg>
          </a>
        </div>
        <?php elseif (($capped || $__hasRest) && $__moreLink): ?>
        <div class="hc-cats-more-wrap hc-cats-more-v-<?= hc_esc($catsStyle) ?> hc-cats-more-r-<?= hc_esc($catsRadius) ?>">
          <?php /* data-hc-more is what lets the client expand in place instead of following
                   the link. It is emitted ONLY in expand mode, so "page" navigates — the
                   href is identical either way and neither mode needs JavaScript. */ ?>
          <a class="hc-cats-more" href="<?= hc_esc(hc_u('cats=all')) ?>"<?= $catsAction === 'expand' ? ' data-hc-more' : ($catsAction === 'paginate' ? ' data-hc-paginate' : '') ?>>
            <span class="hc-cats-more-label"><?= $catsAction === 'paginate' ? hc_esc($__t('see_more_cats', 'See more')) : hc_esc($__t('show_all_cats','Show all categories')) . ' (' . (int)$__restTotal . ')' ?></span>
            <?php if ($catsAction !== 'paginate'): ?><span class="hc-cats-more-rest" aria-hidden="true">+<?= max(0, $__restTotal - $__shownNow) ?></span><?php endif; ?>
            <svg class="hc-cats-more-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
          </a>
          <?php if ($catsAction === 'paginate'): ?>
          <?php /* PHASE10H_2026-08-11 — the dot rail. Built empty; the paginator JS fills
                   it with one radio-style dot per page. With JS off the anchor above is a
                   normal link to the browse page, so nothing is ever unreachable. */ ?>
          <div class="hc-cats-dots" role="tablist" aria-label="<?= hc_esc($__t('cats_pages', 'Category pages')) ?>" data-page-label="<?= hc_esc($__t('cats_page_n', 'Page {n}')) ?>"></div>
          <?php endif; ?>
        </div>
        <?php /* PHASE_HC_SUBCAT_MORE_2026-08-24 — the carousel's CSS moved to
                 hc-sheet-6.css. It is static, and the SUBCATEGORY section on a
                 category page now uses the same engine, so an inline block in the
                 home section could not reach it. Extracting it also takes ~2KB back
                 off help.php's inline-CSS budget instead of duplicating it. */ ?>
        <?php elseif ($showAll && $limit > 0 && $catTotal > $limit): ?>
        <div class="hc-cats-more-wrap">
          <a class="hc-cats-more hc-cats-less" href="<?= hc_esc(hc_u("")) ?>"><?= hc_esc($__t("show_fewer", "Show fewer categories")) ?></a>
        </div>
        <?php endif; ?>
      </section>
        <?php else: ?>
      <section class="hc-empty hc-state hc-state-setup" aria-labelledby="hc-empty-categories-title">
        <div class="hc-state-visual" aria-hidden="true"><span></span><strong>00</strong></div>
        <div class="hc-state-copy">
          <span class="hc-state-kicker"><?= hc_esc($__t("knowledge_base", "Knowledge base")) ?></span>
          <h2 id="hc-empty-categories-title"><?= hc_esc($__t("cats_empty_title", "Nothing here yet")) ?></h2>
          <p><?= hc_esc($__t("cats_empty_body", "Articles will appear here once they are published.")) ?></p>
          <div class="hc-state-actions"><a class="hc-state-action is-primary" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a></div>
        </div>
      </section>
        <?php endif;
    elseif ($showPopular && $articles): ?>
      <section class="hc-pop-shell hc-pop-shell-<?= hc_esc($layout) ?>">
        <div class="hc-sec hc-sec-<?= hc_esc($layout) ?>"><span class="hc-sec-label hc-label-popular"><?= hc_esc($txtPopularLabel) ?></span><span class="hc-sec-line" aria-hidden="true"></span></div>
        <div class="hc-pop-list hc-pop-list-<?= hc_esc($layout) ?> hc-cards-<?= hc_esc($cardStyle) ?>" role="list"><?php foreach (array_values($articles) as $i=>$art) echo hc_pop_card($art,$i,$layout); ?></div>
      </section>
    <?php else: ?>
      <section class="hc-empty hc-state hc-state-setup" aria-labelledby="hc-empty-articles-title">
        <div class="hc-state-visual" aria-hidden="true"><span></span><strong>00</strong></div>
        <div class="hc-state-copy">
          <span class="hc-state-kicker"><?= hc_esc($__t("knowledge_base", "Knowledge base")) ?></span>
          <h2 id="hc-empty-articles-title"><?= hc_esc($__t("empty_articles_title", "No articles published yet")) ?></h2>
          <p><?= hc_esc($__t("cats_empty_body", "Articles will appear here once they are published.")) ?></p>
          <div class="hc-state-actions"><a class="hc-state-action is-primary" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a></div>
        </div>
      </section>
    <?php endif;
    $main = (string)ob_get_clean();
    /* THE HOME IS A STACK OF OPTIONAL SECTIONS, each with its own above/below
     * choice around the main content. Featured, News and Quick links all follow
     * the same rule, so an operator can compose the page without a page builder:
     * turn a section on, say where it sits, done. */
    global $hcHomeNewsHtml, $hcHomeQuickHtml, $hcHomeCtaHtml,
           $homeNewsPosition, $homeQuickPosition, $homeCtaPosition, $_settings;

    /* PHASE5_2026-08-06 — the order WITHIN each group is now a setting.
     *
     * The position keys still decide which side of the category grid a section
     * lands on; this decides the sequence among the ones sharing a side. Those are
     * two different questions, which is why the plan's suggestion to retire the
     * position keys was not taken — doing that would silently move every section
     * on every live site. */
    $__sections = [
        'featured' => [$featHtml,                        $homeFeaturedPosition ?? 'above'],
        'news'     => [(string)($hcHomeNewsHtml  ?? ''), $homeNewsPosition  ?? 'below'],
        'quick'    => [(string)($hcHomeQuickHtml ?? ''), $homeQuickPosition ?? 'below'],
        'cta'      => [(string)($hcHomeCtaHtml   ?? ''), $homeCtaPosition   ?? 'below'],
        /* PHASE8_2026-08-08 — the standalone quick-links module. Its own section key,
         * so it takes part in home_sections_order like every other band and can be
         * placed independently of the tab rail it is NOT a variant of. */
        'links'    => [hc_links_block(), (strtolower((string)($GLOBALS['_settings']['hc_links_position'] ?? 'below')) === 'above' ? 'above' : 'below')],
    ];

    /* PHASE8_2026-08-10 — the FAQ block. It is in the ORDER MAP like every other section,
     * so home_sections_order can move it without a second mechanism.
     *
     * It renders '' whenever nothing carries help_class='faq' — which is every install
     * today — and the order loop already skips empty sections. So this is inert until a
     * content pass runs, which is exactly what plan §8 asks for. */
    $__faqHtml = '';
    if (!in_array(strtolower((string)($_settings['hc_faq_enabled'] ?? '1')), ['', '0', 'off', 'false', 'no'], true)) {
        $__faqRows = class_exists('\OpsIQ\Kb\HelpCenter')
            ? \OpsIQ\Kb\HelpCenter::articlesByClass('faq', (string)($GLOBALS['_siteKey'] ?? ''), max(1, min(40, (int)($_settings['hc_faq_limit'] ?? 8))), (string)($GLOBALS['_locale'] ?? ''))
            : [];
        /* PHASE_HC_LISTING_I18N_2026-08-17 — hc_render_faq() echoes page_title and excerpt
         * straight from these rows with no overlay, so the whole FAQ accordion rendered in
         * English on every translated page. 439 articles carry help_class='faq' on this
         * workspace; the block is currently switched off here, so this was latent — and
         * live for anyone who turns it on. */
        try {
            $__faqLoc = (string)($GLOBALS['_locale'] ?? '');
            if ($__faqLoc !== '' && $__faqRows && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
                \OpsIQ\Kb\HcTranslator::overlayArticles($__faqRows, (string)($GLOBALS['_siteKey'] ?? ''), $__faqLoc);
            }
        } catch (\Throwable $e) { /* an English FAQ beats no FAQ */ }
        if ($__faqRows) $__faqHtml = hc_render_faq($__faqRows);
    }
    $__sections['faq'] = [$__faqHtml, strtolower((string)($_settings['hc_faq_position'] ?? 'below')) === 'above' ? 'above' : 'below'];

    /* PHASE_HC_ANNOUNCE_BAR_2026-08-14 — the announcement is no longer a home SECTION.
     * It is a bar across the top of every page now, like the portal's, so it is rendered
     * with the chrome beside hc_render_nav() and has no place in this order. */


    /* A section missing from the list keeps its historical place AFTER the named
     * ones, so a partial list is a valid partial answer. Dropping the unnamed ones
     * would turn a reordering control into a way to hide a section by accident. */
    $__orderRaw = trim((string)($_settings['home_sections_order'] ?? ''));
    $__order = [];
    if ($__orderRaw !== '') {
        foreach (explode(',', $__orderRaw) as $__s) {
            $__s = trim($__s);
            if (isset($__sections[$__s]) && !in_array($__s, $__order, true)) $__order[] = $__s;
        }
    }
    foreach (array_keys($__sections) as $__s) {
        if (!in_array($__s, $__order, true)) $__order[] = $__s;
    }

    /* PHASE9e_2026-08-10 — on the browse surface the categories ARE the page. Filtered
     * here rather than inside each renderer, so a section added later is excluded by
     * default instead of quietly turning up on a page meant to show one thing. */
    if (!empty($GLOBALS['_isBrowse'])) $__order = [];

    $above = ''; $below = '';
    foreach ($__order as $__key) {
        [$html, $pos] = $__sections[$__key];
        if ($html === '') continue;
        if ($pos === 'below') $below .= $html; else $above .= $html;
    }
    return $above . $main . $below;
}
/* ── AJAX: the assistant, SAME-ORIGIN ─────────────────────────────────────────
 *
 * The launcher must call its API on the page's own origin: cross-origin means no
 * cookies, and the endpoint does not return an allow-origin header anyway.
 *
 * On a PROXIED host that is a problem, because every path there routes to this
 * file — `/opsiq/ajax_api.php` comes back as the help centre PAGE with
 * content-type text/html, `r.json()` throws, and the reader is told "Network
 * error. Please try again." while nothing is actually wrong with the network.
 *
 * So this file answers for it, exactly as it already answers for `_hcajax` and
 * the `hca` beacon: same origin, same cookies, real JSON. The action name is
 * whitelisted rather than passed through — this must not become a way to reach
 * any ajax action on the host. */
if (!empty($_GET['_hcasst'])) {
    $__asstAction = preg_replace('/[^a-z0-9_]/i', '', (string)$_GET['_hcasst']);
    /* THE ACTIONS THE MODULE ACTUALLY CALLS. My first list was written from
     * memory and was wrong in both directions: three of its names do not exist,
     * and it missed the three `portal_chat_*` calls behind "Talk to a person" —
     * so clicking that answered "Unknown action." Taken from the module now,
     * not from memory. Still a whitelist: this must not become a way to reach
     * any ajax action on the host.
     *
     * 2026-09-14 — `portal_csrf_token` is the HOST adapter's call, not the
     * module's. portal_ask / portal_chat_start / portal_chat_send are classified
     * browser mutations (PublicMutationGuard) and answer 403 without a
     * synchroniser token, which a guest can only obtain by minting one. The
     * mint MUST come through this same proxy: on a proxied host the real ajax
     * path returns this page as HTML, and the token has to land in THIS
     * surface's session (see the /help session-route rule in bootstrap/init.php)
     * or the next call can never verify it. Every action the widget can reach
     * was dead from 2026-08-26 to 2026-09-14 for want of this one entry. */
    $__asstAllow  = ['portal_ask', 'portal_chat_start', 'portal_chat_send', 'portal_chat_poll', 'portal_csrf_token'];
    if (!in_array($__asstAction, $__asstAllow, true)) {
        header('Content-Type: application/json; charset=utf-8');
        header('X-Robots-Tag: noindex');
        echo json_encode(['success' => false, 'error' => 'Unknown action.']);
        exit;
    }
    /* THE HANDLER MUST ANSWER FROM THIS SURFACE'S CONFIG, not the portal's.
     * opsiq.portal_ask.php declares its config reader behind a
     * `function_exists` guard, which is the extension point: defining ours
     * FIRST means the handler reads the help centre's own enabled/ai/wording
     * and the two surfaces stay genuinely independent. */
    if (!function_exists('opsiq_portal_assistant_config')) {
        $GLOBALS['__hcAsstCfg'] = hc_assistant_config((array)($_settings ?? []));
        function opsiq_portal_assistant_config(): array {
            return (array)($GLOBALS['__hcAsstCfg'] ?? []);
        }
    }
    $_GET['ajax'] = $__asstAction;
    $_REQUEST['ajax'] = $__asstAction;
    require __DIR__ . '/opsiq/ajax_api.php';
    exit;
}

/* ── AJAX: autocomplete suggest ──────────────────────────────────────────── */
if (!empty($_GET['_hcajax']) && !empty($_GET['_suggest'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('X-Robots-Tag: noindex');
    $sq = trim((string)($_GET['q'] ?? ''));
    if ($sq === '') { echo json_encode(['ok' => true, 'results' => []]); exit; }
    $suggest = HelpCenter::search($sq, $_siteKey, 6);
    /* PHASE_HC_I18N_AI — the autocomplete dropdown showed English titles on a
     * translated page. Overlay before emitting so suggestions match the cards. */
    if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
        try { \OpsIQ\Kb\HcTranslator::overlayArticles($suggest, $_siteKey, $_locale); } catch (\Throwable $e) {}
    }
    $results = [];
    foreach ($suggest as $s) {
        $results[] = [
            'title' => (string)($s['page_title'] ?? ''),
            'slug'  => (string)($s['slug'] ?? ''),
        ];
    }
    /* PHASE10K5_2026-08-11 — the command palette rides this same endpoint with
     * &pal=1 and additionally gets matching CATEGORIES, so the panel can offer
     * both kinds of jump. Plain autocomplete callers see an unchanged shape. */
    $out = ['ok' => true, 'results' => $results];
    if (!empty($_GET['pal'])) {
        $cats = [];
        $needle = function_exists('mb_strtolower') ? mb_strtolower($sq) : strtolower($sq);
        /* PHASE_HC_LISTING_I18N_2026-08-17 — the palette returned ENGLISH category names
         * alongside FRENCH article titles in the SAME JSON response, because this list is
         * fetched raw and no overlay is applied. The translations existed and were simply
         * never used. Matching happens AFTER the overlay so the visitor's own words match
         * what they can actually see. */
        $__palCats = (array)HelpCenter::listCategories($_siteKey);
        try {
            $__palLoc = (string)($GLOBALS['_locale'] ?? '');
            if ($__palLoc !== '' && $__palCats && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
                \OpsIQ\Kb\HcTranslator::overlayCategories($__palCats, (string)$_siteKey, $__palLoc);
            }
        } catch (\Throwable $e) { /* English names beat no names */ }
        foreach ($__palCats as $c) {
            $n = (string)($c['name'] ?? '');
            $hay = function_exists('mb_strtolower') ? mb_strtolower($n) : strtolower($n);
            if ($n !== '' && strpos($hay, $needle) !== false) {
                $cats[] = ['name' => $n, 'slug' => (string)($c['slug'] ?? ''), 'count' => (int)($c['article_count'] ?? 0)];
                if (count($cats) >= 4) break;
            }
        }
        $out['cats'] = $cats;
    }
    echo json_encode($out);
    exit;
}

/* ── View HTML builder (shared between AJAX + full render) ───────────────── */
/**
 * PHASE_HC_WIDGET_LOOK (2026-09-14) — the Help widget's "Help look": the panel's OWN
 * markup, a copy of the storefront help panel's structure imported whole and owned by the
 * Help Center. Nothing of the Help Center page's presentation is reused (owner: "you are
 * not to use any design or looks or cards or presentation from the old options"; "it's on
 * its own and will display and flow like the storefront's"). What it takes from the Help
 * Center is data, translations and colours.
 *
 * Views: home (categories), category (sub-categories + articles), search (results),
 * article (reader + was-this-helpful + related). The head carries the controls IN it:
 * the language menu, the sun / moon and the close, so nothing floats over the frame.
 * Functional hooks kept from the Help Center, because its scripts drive them:
 *   #hc-page                       the router swaps this node on every in-panel click
 *   #hc-hero-form / #hc-hero-input / #hc-suggest-hero / .hc-srch-wrap   search + autocomplete
 *   #hc-feedback-<id> + hcFeedback()                                    the helpful vote
 *   data-hc-wl="lang|theme|close"   bound once by delegation (help.php, widget block)
 */
function hc_render_widget_look(): string {
    global $_article, $_category, $_categories, $_articles, $_related, $_query, $_searchTotal,
           $_articleId, $_siteKey, $siteName, $helpBase, $__t,
           $_locale, $_i18nOn, $_i18nLocales, $_i18nSource, $darkEnabled, $txtSearchPH,
           $_readTime, $_widget, $showFeedback, $txtFeedbackQ, $feedbackCounts, $feedbackCountsMin,
           $widgetHelpLink, $widgetHelpLinkLabel, $showContactPrompt, $txtContactPrompt, $txtContactUrl;
    $S = (array)($GLOBALS['_settings'] ?? []);
    $v = (string)($GLOBALS['_wdVariant'] ?? 'aurora');
    $isSub = ($_article || $_category || $_query !== '');
    $headOn = !empty($GLOBALS['_wdHead']);
    $brand = (string)($GLOBALS['brandColor'] ?? '#6c5ce7');
    $headBg = class_exists('\\OpsIQ\\Kb\\HelpCenter') ? \OpsIQ\Kb\HelpCenter::dsColor((string)($S['ds_widget_header'] ?? '')) : '';
    $headFg = class_exists('\\OpsIQ\\Kb\\HelpCenter') ? \OpsIQ\Kb\HelpCenter::dsColor((string)($S['ds_widget_header_text'] ?? '')) : '';
    if ($headBg === '' || stripos($headBg, 'gradient') !== false) $headBg = $brand;
    if ($headFg === '' || stripos($headFg, 'gradient') !== false) $headFg = '';
    $a2 = function_exists('opsiq_hc_widget_accent2') ? opsiq_hc_widget_accent2($brand) : $brand;
    /* The panel background: WHITE for every variant (the theme's card in dark mode) unless
       the operator paints "Panel background" in the Colours studio (owner, 2026-09-14). */
    $panelBg = class_exists('\\OpsIQ\\Kb\\HelpCenter') ? \OpsIQ\Kb\HelpCenter::dsColor((string)($S['ds_widget_panel'] ?? '')) : '';
    /* The head's photo (widget_hero_image + widget_hero_overlay, owner 2026-09-14): drawn
       under the head's colour wash on every variant, the rings still over it. The same URL
       guard as the page hero, so a stored value can never become a javascript: or data:
       url inside the CSS url(). */
    $heroImg = trim((string)($S['widget_hero_image'] ?? ''));
    if ($heroImg !== '' && !preg_match('~^(https?://|/)~i', $heroImg)) $heroImg = '';
    if ($heroImg !== '' && function_exists('hc_asset_url')) $heroImg = (string)hc_asset_url($heroImg);   // a stored /path serves from the platform, as the page hero's does
    $heroImg = str_replace(['"', "'", '\\', '(', ')', ';'], '', $heroImg);
    $heroOv = max(0, min(90, (int)($S['widget_hero_overlay'] ?? 55))) / 100;
    $style = '--wd-head:' . hc_esc($headBg) . ';--wd-a2:' . hc_esc($a2) . ($headFg !== '' ? ';--wd-fg:' . hc_esc($headFg) : '') . ($panelBg !== '' ? ';--wd-panel:' . hc_esc($panelBg) : '')
           . ($heroImg !== '' ? ';--wd-hero-img:url(&quot;' . hc_esc($heroImg) . '&quot;);--wd-hero-ov:' . $heroOv : '');

    /* Words: the Help Center's own translated strings and the operator's lines. The head
       reads the same on every view, exactly as the storefront's does; the view's own title
       lives in the body. */
    $name = trim((string)($S['site_name'] ?? ''));
    $nameDefault = ($name === '' || strcasecmp($name, 'Help Center') === 0);
    $kick = trim((string)($S['widget_kicker'] ?? ''));
    if ($kick === '' && !$nameDefault) $kick = $name;
    $title = (string)$__t('home', 'Help Center');
    $intro = trim((string)($S['widget_intro'] ?? ''));
    if ($intro === '') $intro = (string)$__t('widget_intro', 'Answers, guides and product help, right here.');
    $ph = trim((string)($txtSearchPH ?? '')) ?: (string)$__t('search_btn', 'Search');

    $cats = is_array($_categories) && $_categories ? $_categories
          : (class_exists('\\OpsIQ\\Kb\\HelpCenter') ? (array)\OpsIQ\Kb\HelpCenter::listCategories((string)$_siteKey) : []);
    $catUrl = static fn(array $c): string => hc_u('cat=' . urlencode((string)($c['slug'] ?? '')));
    $artUrl = static fn(array $a): string => hc_u('article=' . urlencode((string)($a['slug'] ?? '')));
    $countText = function (array $c) use ($__t): string {
        $n = (int)($c['descendant_article_count'] ?? $c['article_count'] ?? 0);
        return $n . ' ' . (string)$__t($n === 1 ? 'article_one' : 'article_many', $n === 1 ? 'article' : 'articles');
    };
    $icoHtml = function (array $c): string {
        $h = function_exists('hc_cat_icon_html') ? hc_cat_icon_html((string)($c['icon'] ?? '')) : '';
        if ($h !== '') return $h;
        $n = trim((string)($c['name'] ?? ''));
        $first = $n !== '' ? (function_exists('mb_substr') ? mb_substr($n, 0, 1) : substr($n, 0, 1)) : '?';
        return hc_esc(function_exists('mb_strtoupper') ? mb_strtoupper($first) : strtoupper($first));
    };
    $catCard = function (array $c) use ($catUrl, $icoHtml, $countText): string {
        return '<a class="hc-wl-cat" href="' . hc_esc($catUrl($c)) . '">'
             . '<span class="hc-wl-ico" aria-hidden="true">' . $icoHtml($c) . '</span>'
             . '<span class="hc-wl-cat-body"><span class="hc-wl-cat-name">' . hc_esc((string)($c['name'] ?? '')) . '</span>'
             . '<span class="hc-wl-cat-sub">' . hc_esc($countText($c)) . '</span></span>'
             . '<span class="hc-wl-arrow" aria-hidden="true">&#8594;</span></a>';
    };
    $artCard = function (array $a) use ($artUrl, $__t): string {
        $t = (string)($a['page_title'] ?? '');
        $canRead = !class_exists('\\OpsIQ\\Kb\\HelpCenter') || \OpsIQ\Kb\HelpCenter::canReadArticle($a);
        $exc = $canRead
            ? (function_exists('hc_clean_excerpt') ? hc_clean_excerpt($t, $a['excerpt'] ?? '') : (string)($a['excerpt'] ?? ''))
            : (string)$__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.');
        return '<a class="hc-wl-art" href="' . hc_esc($artUrl($a)) . '"><b class="hc-wl-art-title">' . hc_esc($t) . '</b>'
             . ($exc !== '' ? '<span class="hc-wl-art-exc">' . hc_esc($exc) . '</span>' : '')
             . '<span class="hc-wl-chip" aria-hidden="true">&#8599;</span></a>';
    };
    /* The storefront's back control: "← Back", to the view above. */
    $back = function (string $url) use ($__t): string {
        return '<a class="hc-wl-back" href="' . hc_esc($url) . '">&larr; ' . hc_esc($__t('back', 'Back')) . '</a>';
    };
    /* "Clean scrolling" (widget_clean_scroll, ON by default): in this look only the body
       scrolls, so the rail to hide is the body's; the sheet reads it from a class. */
    $cleanRaw = $S['widget_clean_scroll'] ?? true;
    $clean = is_bool($cleanRaw) ? $cleanRaw : (($cleanRaw === '' || $cleanRaw === null) ? true : !in_array(strtolower((string)$cleanRaw), ['0','off','false','no'], true));
    $classes = 'hc-widget hc-wd hc-wd-' . hc_esc($v) . ($isSub ? ' hc-wd-sub' : '') . ($headOn ? '' : ' hc-wd-nohead') . ($clean ? ' hc-wd-clean' : '') . ($heroImg !== '' ? ' hc-wd-hero-img' : '');
    /* A reader setting reads OFF on '', 0, off, false, no — the Help Center's own rule. */
    $off = static fn($v, string $d = ''): bool => in_array(strtolower(trim((string)($v ?? $d))), ['', '0', 'off', 'false', 'no'], true);
    /* NO READING PROGRESS IN THIS LOOK (owner, 2026-09-15: "as you scroll there a line
       blocking the view, remove it"). The panel is a short, fixed-height column whose
       body is the only thing that scrolls, so the hairline sat pinned directly under the
       sticky search and cut across the article the whole way down — the cost of a 3px
       sticky bar is different in a 470px panel than on a full-width page. `reader_progress`
       is UNTOUCHED and still governs the standalone Help Center article page (the
       .hc-read-progress bar in hc-sheet-6.css); this look simply does not offer it. */

    ob_start(); ?>
<div id="hc-page" class="<?= $classes ?>" style="<?= $style ?>">
  <header class="hc-wl-head">
    <div class="hc-wl-head-text">
    <?php if ($kick !== ''): ?><small class="hc-wl-kicker"><?= hc_esc($kick) ?></small><?php endif; ?>
    <h2 class="hc-wl-title"><?= hc_esc($title) ?></h2>
    <p class="hc-wl-intro"><?= hc_esc($intro) ?></p>
    </div>
    <div class="hc-wl-ctl">
      <?php if ($_i18nOn && is_array($_i18nLocales) && count($_i18nLocales) > 1 && function_exists('opsiq_hc_locale_badge')):
          $LL = function_exists('opsiq_portal_locales') ? opsiq_portal_locales() : [];
          [, $curShort] = opsiq_hc_locale_badge((string)$_locale); ?>
      <div class="hc-wl-lang">
        <button type="button" class="hc-wl-lang-btn" data-hc-wl="lang" aria-haspopup="true" aria-expanded="false" aria-label="<?= hc_esc($__t('language', 'Language')) ?>">
          <?= hc_lang_flag_img((string)$_locale) ?><span><?= hc_esc($curShort) ?></span>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
        </button>
        <div class="hc-wl-lang-menu" role="menu" hidden>
          <?php foreach ($_i18nLocales as $code): $meta = $LL[$code] ?? null; if (!$meta) continue;
                /* ?lang= on EVERY item, source included — a clean URL lets the stale
                   hc_lang cookie answer instead, so the source language became unreachable.
                   See the note on the main picker. */
                $href = hc_u_lang($code); ?>
          <a class="hc-wl-lang-item<?= $code === $_locale ? ' is-active' : '' ?>" role="menuitem" href="<?= hc_esc($href) ?>" lang="<?= hc_esc($code) ?>" data-lang="<?= hc_esc($code) ?>">
            <?= hc_lang_flag_img((string)$code) ?><span><?= hc_esc((string)$meta['native']) ?></span>
            <?php if ($code === $_locale): ?><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg><?php endif; ?>
          </a>
          <?php endforeach; ?>
        </div>
      </div>
      <?php endif; ?>
      <?php if (!empty($darkEnabled)): ?>
      <button type="button" class="hc-wl-tog" data-hc-wl="theme" aria-label="<?= hc_esc($__t('theme_switch', 'Switch between light and dark')) ?>" title="<?= hc_esc($__t('theme_toggle', 'Light / dark')) ?>">
        <svg class="hc-wl-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
        <svg class="hc-wl-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 1.6v2.2M12 20.2v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M1.6 12h2.2M20.2 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6"/></svg>
      </button>
      <?php endif; ?>
      <button type="button" class="hc-wl-close" data-hc-wl="close" aria-label="<?= hc_esc($__t('close', 'Close')) ?>">&times;</button>
    </div>
  </header>
  <div class="hc-wl-search hc-srch-wrap" role="search">
    <form class="hc-wl-form" id="hc-hero-form" action="<?= hc_esc($helpBase) ?>" method="get" autocomplete="off"><?= hc_hidden() ?>
      <input type="search" name="q" id="hc-hero-input" data-hc-search="hero" placeholder="<?= hc_esc($ph) ?>" value="<?= hc_esc($_query) ?>" autocomplete="off" enterkeyhint="search" aria-label="<?= hc_esc($ph) ?>" aria-autocomplete="list" aria-controls="hc-suggest-hero" aria-expanded="false">
    </form>
    <?php /* NO autocomplete dropdown in this look. The storefront panel answers AS YOU TYPE,
             in the body (owner, 2026-09-14: "it show results as you type"), so a floating list
             of six titles over the results would be the same answer twice. wireSearch() binds
             nothing without #hc-suggest-hero, which is exactly what this look wants. */ ?>
    <div class="hc-search-err" role="alert" data-hc-wl-err hidden></div>
  </div>
  <main class="hc-wl-body">
<?php if ($_article):
        $art = (array)$_article;
        $canRead = !class_exists('\\OpsIQ\\Kb\\HelpCenter') || \OpsIQ\Kb\HelpCenter::canReadArticle($art);
        $backUrl = $_category ? $catUrl((array)$_category) : hc_u();
        $fmt = trim((string)($art['content_formatted'] ?? ''));
        $body = function_exists('hc_article_body') ? hc_article_body($fmt !== '' ? $fmt : (string)($art['content_text'] ?? ''), (string)($art['page_title'] ?? '')) : '';
        $showFb = !empty($showFeedback);
        $fbQ = trim((string)($txtFeedbackQ ?? '')) ?: (string)$__t('feedback_q', 'Was this answer helpful?');
        $rel = is_array($_related) ? (isset($_related['items']) && is_array($_related['items']) ? $_related['items'] : $_related) : [];
        $artKick = $kick !== '' ? $kick : $title;
        /* Every reader setting the Help Center's own article page honours, read from the
           SAME keys (reader_*, feedback_counts*, widget_help_link*, show_contact_prompt) and
           drawn in this look's own dress: the meta row, the action pills, the tag chips, the
           vote counts, the neighbours, the way out to the full page and the human. */
        $svg = static fn(string $d, string $w = '2'): string => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="' . $w . '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $d . '</svg>';
        $upd = trim((string)($art['updated_at'] ?? '')); $updTs = $upd !== '' ? (int)strtotime($upd) : 0;
        $rvd = trim((string)($art['reviewed_at'] ?? '')); $rvTs = $rvd !== '' ? (int)strtotime($rvd) : 0;
        $views = (int)($art['views_count'] ?? 0);
        $freshDays = max(0, min(730, (int)($S['reader_freshness_days'] ?? 0)));
        $meta = [];
        if ($_category) $meta[] = '<a class="hc-wl-meta-cat" href="' . hc_esc($catUrl((array)$_category)) . '">' . hc_esc((string)($_category['name'] ?? '')) . '</a>';
        if (!$off($S['reader_updated'] ?? null) && $updTs > 0) $meta[] = '<span class="hc-wl-meta-item">' . $svg('<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/>') . '<time datetime="' . hc_esc(date('c', $updTs)) . '">' . hc_esc(hc_date_local($updTs)) . '</time></span>';
        if (!$off($S['reader_views'] ?? null) && $views > 0) $meta[] = '<span class="hc-wl-meta-item">' . $svg('<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>') . number_format($views) . '</span>';
        if (!$off($S['reader_reviewed'] ?? null) && $rvTs > 0) $meta[] = '<span class="hc-wl-meta-item" title="' . hc_esc($__t('reviewed_on_title', 'An editor confirmed this article is still accurate on this date')) . '">' . $svg('<path d="M20 6 9 17l-5-5"/>', '2.2') . hc_esc($__t('reviewed_on', 'Reviewed')) . ' <time datetime="' . hc_esc(date('c', $rvTs)) . '">' . hc_esc(hc_date_local($rvTs)) . '</time></span>';
        if ($freshDays > 0 && $updTs > 0 && (time() - $updTs) <= $freshDays * 86400) $meta[] = '<span class="hc-wl-fresh">' . hc_esc($__t('recently_updated', 'Recently updated')) . '</span>';
        if ((int)$_readTime > 0 && $canRead) $meta[] = '<span class="hc-wl-meta-item">' . $svg('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>') . (int)$_readTime . ' ' . hc_esc($__t('min_read', 'min read')) . '</span>';
        $actsOn = !$off($S['reader_actions'] ?? null, '1');
        $actsStyle = (string)($S['reader_actions_style'] ?? 'both');
        if (!in_array($actsStyle, ['icons', 'labels', 'both'], true)) $actsStyle = 'both';
        $ccOn = !$off($S['reader_code_copy'] ?? null, '1');
        $tags = array_slice(array_values(array_filter(array_map('trim', explode(',', (string)($art['help_tags'] ?? ''))), static fn($t) => $t !== '')), 0, 10);
        $tagTr = [];
        /* Display the translated tag, search the original: the index is the source language. */
        if ($tags && !empty($_i18nOn) && (string)$_locale !== '' && (string)$_locale !== (string)$_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
            try { $tagTr = (array)\OpsIQ\Kb\HcTranslator::tagsOverlay((string)$_siteKey, (string)$_locale); } catch (\Throwable $e) { $tagTr = []; }
        }
        $fbMode = (string)($feedbackCounts ?? 'hide'); $fbMin = max(0, (int)($feedbackCountsMin ?? 3));
        $up = (int)($art['helpful_count'] ?? 0); $down = (int)($art['unhelpful_count'] ?? 0); $tot = $up + $down;
        $countsHtml = '';
        if ($fbMode !== 'hide' && $tot >= max(1, $fbMin)) {
            $countsHtml = $fbMode === 'ratio'
                ? str_replace(['{n}', '{m}'], ['<strong>' . $up . '</strong>', '<strong>' . $tot . '</strong>'], hc_esc($__t('found_helpful_ratio', '{n} of {m} found this helpful')))
                : '<span>&#128077; ' . $up . '</span><span>&#128078; ' . $down . '</span>';
        }
        $prev = $next = null;
        if (!$off($S['reader_prevnext'] ?? null)) {
            $pool = is_array($_articles) ? array_values($_articles) : [];
            if (count($pool) < 2 && $_category && (int)($_category['id'] ?? 0) > 0) {
                try { $pool = array_values((array)\OpsIQ\Kb\HelpCenter::listPublicArticles((string)$_siteKey, (int)$_category['id'], 200, 0, true, (string)$_locale)); } catch (\Throwable $e) { $pool = []; }
            }
            if (count($pool) > 1 && (string)$_locale !== '' && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
                try { \OpsIQ\Kb\HcTranslator::overlayArticles($pool, (string)$_siteKey, (string)$_locale); } catch (\Throwable $e) { /* English neighbours beat no neighbours */ }
            }
            $slug = trim((string)($art['slug'] ?? '')); $at = -1;
            foreach ($pool as $ix => $s) { if (trim((string)($s['slug'] ?? '')) === $slug) { $at = $ix; break; } }
            if ($at >= 0) { $prev = $at > 0 ? (array)$pool[$at - 1] : null; $next = $at < count($pool) - 1 ? (array)$pool[$at + 1] : null; }
        }
        $whUrl = (!empty($_widget) && !empty($widgetHelpLink) && function_exists('hc_widget_help_url')) ? (string)hc_widget_help_url((string)($art['slug'] ?? '')) : '';
        $contact = !empty($showContactPrompt) ? trim((string)($txtContactPrompt ?? '')) : '';
        $contactUrl = trim((string)($txtContactUrl ?? ''));
?>
    <?= $back($backUrl) ?>
    <article class="hc-wl-article">
      <span class="hc-wl-article-kicker"><?= hc_esc($artKick) ?></span>
      <h1><?= hc_esc((string)($art['page_title'] ?? '')) ?></h1>
      <?php if ($meta): ?><div class="hc-wl-meta"><?= implode('', $meta) ?></div><?php endif; ?>
      <?php if ($actsOn && $canRead): ?>
      <div class="hc-wl-acts hc-wl-acts-<?= hc_esc($actsStyle) ?>">
        <button type="button" data-hc-wl-act="copy" data-copied="<?= hc_esc($__t('act_copied', 'Copied')) ?>"><?= $svg('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>') ?><span><?= hc_esc($__t('act_copy', 'Copy link')) ?></span></button>
        <button type="button" data-hc-wl-act="share" data-copied="<?= hc_esc($__t('act_copied', 'Copied')) ?>"><?= $svg('<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/>') ?><span><?= hc_esc($__t('act_share', 'Share')) ?></span></button>
        <button type="button" data-hc-wl-act="print"><?= $svg('<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>') ?><span><?= hc_esc($__t('act_print', 'Print')) ?></span></button>
      </div>
      <?php endif; ?>
      <?php if ($canRead): ?>
      <div class="hc-wl-content<?= $ccOn ? ' hc-wl-cc' : '' ?>" id="hc-article-body"><?= $body ?></div>
      <?php else: ?>
      <div class="hc-wl-gate"><?= hc_esc($__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.')) ?></div>
      <?php endif; ?>
      <?php if ($tags): ?>
      <div class="hc-wl-tags" aria-label="<?= hc_esc($__t('tags', 'Tags')) ?>"><?php foreach ($tags as $tg) { $show = $tagTr[mb_strtolower($tg)] ?? $tg; echo '<a class="hc-wl-tag" href="' . hc_esc(hc_u('q=' . urlencode($tg))) . '"># ' . hc_esc((string)$show) . '</a>'; } ?></div>
      <?php endif; ?>
      <?php if ($rel): ?>
      <h3 class="hc-wl-h3 hc-wl-gap"><?= hc_esc(trim((string)($S['text_related_label'] ?? '')) ?: (string)$__t('related', 'Related articles')) ?></h3>
      <ul class="hc-wl-related"><?php foreach ($rel as $r) { if (is_array($r) && !empty($r['slug'])) echo '<li><a href="' . hc_esc($artUrl($r)) . '">' . hc_esc((string)($r['page_title'] ?? '')) . '</a></li>'; } ?></ul>
      <?php endif; ?>
      <?php if ($showFb && $canRead && (int)$_articleId > 0): ?>
      <section class="hc-wl-rating" id="hc-feedback-<?= (int)$_articleId ?>">
        <div><small><?= hc_esc($__t('feedback_kicker', 'Your feedback')) ?></small><b><?= hc_esc($fbQ) ?></b><?php if ($countsHtml !== ''): ?><p class="hc-wl-counts"><?= $countsHtml ?></p><?php endif; ?></div>
        <div class="hc-wl-rating-actions">
          <button type="button" onclick="hcFeedback(<?= (int)$_articleId ?>,true)"><span aria-hidden="true">&#8593;</span><?= hc_esc($__t('feedback_yes', 'Yes, helpful')) ?></button>
          <button type="button" onclick="hcFeedback(<?= (int)$_articleId ?>,false)"><span aria-hidden="true">&#8595;</span><?= hc_esc($__t('feedback_no', 'Not really')) ?></button>
        </div>
      </section>
      <?php endif; ?>
      <?php if ($prev || $next): ?>
      <nav class="hc-wl-nav" aria-label="<?= hc_esc($__t('nav_within_category', 'More in this category')) ?>">
        <?php if ($prev): ?><a class="hc-wl-nav-l" href="<?= hc_esc($artUrl($prev)) ?>" rel="prev"><span class="hc-wl-nav-k"><?= $svg('<path d="M19 12H5M11 18l-6-6 6-6"/>', '2.2') ?><?= hc_esc($__t('nav_prev', 'Previous')) ?></span><span class="hc-wl-nav-t"><?= hc_esc((string)($prev['page_title'] ?? '')) ?></span></a><?php else: ?><span></span><?php endif; ?>
        <?php if ($next): ?><a class="hc-wl-nav-r" href="<?= hc_esc($artUrl($next)) ?>" rel="next"><span class="hc-wl-nav-k"><?= hc_esc($__t('nav_next', 'Next')) ?><?= $svg('<path d="M5 12h14M13 6l6 6-6 6"/>', '2.2') ?></span><span class="hc-wl-nav-t"><?= hc_esc((string)($next['page_title'] ?? '')) ?></span></a><?php endif; ?>
      </nav>
      <?php endif; ?>
      <?php if ($whUrl !== ''): ?>
      <a class="hc-wl-ext" href="<?= hc_esc($whUrl) ?>" target="_blank" rel="noopener noreferrer"><?= $svg('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>') ?><span><?= hc_esc((string)$widgetHelpLinkLabel) ?></span></a>
      <?php endif; ?>
      <?php if ($contact !== ''): ?>
      <p class="hc-wl-contact"><?php if ($contactUrl !== ''): ?><a href="<?= hc_esc($contactUrl) ?>" target="_blank" rel="noopener noreferrer"><?= hc_esc($contact) ?></a><?php else: ?><?= hc_esc($contact) ?><?php endif; ?></p>
      <?php endif; ?>
    </article>
<?php elseif ($_category):
        $cat = (array)$_category; $cid = (int)($cat['id'] ?? 0);
        $kids = array_values(array_filter($cats, static fn($c) => (int)($c['parent_id'] ?? 0) === $cid && $cid > 0));
        $anc = function_exists('hc_cat_ancestors') ? (array)hc_cat_ancestors($cat) : [];
        $parent = $anc ? (array)end($anc) : null;
        $arts = is_array($_articles) ? array_values($_articles) : [];
?>
    <?= $back($parent ? $catUrl($parent) : hc_u()) ?>
    <h3 class="hc-wl-h3"><?= hc_esc((string)($cat['name'] ?? '')) ?></h3>
    <?php if ($kids): ?><div class="hc-wl-grid hc-wl-subcats"><?php foreach ($kids as $k) echo $catCard($k); ?></div><?php endif; ?>
    <?php if ($arts): ?><div class="hc-wl-grid" data-hc-wl-list><?php foreach ($arts as $i => $a) echo str_replace('class="hc-wl-art"', 'class="hc-wl-art' . ($i >= 30 ? ' hc-wl-hidden' : '') . '"', $artCard((array)$a)); ?></div>
    <?php if (count($arts) > 30): ?><button type="button" class="hc-wl-more" data-hc-wl-more="reveal"><?= hc_esc($__t('widget_more', 'Show more')) ?></button><?php endif; ?>
    <?php elseif (!$kids): ?><div class="hc-wl-empty"><?= hc_esc($__t('empty_category_title', 'No articles yet')) ?></div><?php endif; ?>
<?php elseif ($_query !== ''):
        $arts = is_array($_articles) ? array_values($_articles) : [];
        $pg = max(1, (int)($GLOBALS['_searchPage'] ?? 1)); $pgs = max(1, (int)($GLOBALS['_searchPages'] ?? 1));
        $nextUrl = ($pgs > $pg) ? hc_u('q=' . urlencode($_query) . '&page=' . ($pg + 1)) : '';
?>
    <?= $back(hc_u()) ?>
    <h3 class="hc-wl-h3"><?= hc_esc($__t('search_results', 'Search results')) ?></h3>
    <?php if ($arts): ?><div class="hc-wl-grid" data-hc-wl-list><?php foreach ($arts as $a) echo $artCard((array)$a); ?></div>
    <?php if ($nextUrl !== ''): ?><button type="button" class="hc-wl-more" data-hc-wl-more="<?= hc_esc($nextUrl) ?>"><?= hc_esc($__t('widget_more', 'Show more')) ?></button><?php endif; ?>
    <?php else: ?><div class="hc-wl-empty"><?= hc_esc($__t('no_results', 'No results. Try another word.')) ?></div><?php endif; ?>
<?php else:
        $top = array_values(array_filter($cats, static fn($c) => (int)($c['id'] ?? 0) > 0 && (int)($c['parent_id'] ?? 0) === 0));
        if (!$top) $top = array_values(array_filter($cats, static fn($c) => (int)($c['id'] ?? 0) > 0));
?>
    <?php /* Popular answers, as the storefront home shows them: the public listing's first
             eight, the same call its panel makes (client_chat_help_articles, limit 8). */
          $pop = [];
          try { $pop = class_exists('\\OpsIQ\\Kb\\HelpCenter') ? (array)\OpsIQ\Kb\HelpCenter::listPublicArticles((string)$_siteKey, null, 8, 0, true, (string)$_locale) : []; } catch (\Throwable $e) { $pop = []; }
          $popLabel = trim((string)($GLOBALS['txtPopularLabel'] ?? '')) ?: (string)$__t('popular_label', 'Popular Articles');
    ?>
    <?php if ($top): ?>
    <h3 class="hc-wl-h3"><?= hc_esc($__t('widget_browse', 'Browse by category')) ?></h3>
    <div class="hc-wl-grid hc-wl-categories"><?php foreach ($top as $c) echo $catCard((array)$c); ?></div>
    <?php endif; ?>
    <?php if ($pop): ?>
    <h3 class="hc-wl-h3<?= $top ? ' hc-wl-gap' : '' ?>"><?= hc_esc($popLabel) ?></h3>
    <div class="hc-wl-grid"><?php foreach ($pop as $a) echo $artCard((array)$a); ?></div>
    <?php endif; ?>
    <?php if (!$top && !$pop): ?><div class="hc-wl-empty"><?= hc_esc($__t('empty_category_title', 'No articles yet')) ?></div><?php endif; ?>
<?php endif; ?>
  </main>
</div>
<?php return (string)ob_get_clean();
}

function hc_build_view(): string {
    global $_searchTotal, $_searchPages, $_searchPage,
           $_article, $_category, $_categories, $_articles, $_related,
           $_articleSlug, $_catSlug, $_query, $_articleId, $_readTime,
           $_totalArticles, $_totalCats, $_siteKey, $siteName, $brandColor,
           $brandRGB, $btnColor, $btnRGB, $heroBase, $helpBase, $_embed,
           $_isHome, $_layout, $_viewType, $_surfaceStyle, $_heroShape, $_cardStyle, $_contentDensity,
           /* PHASE_HC_WIDGET — same trap as always: hc_build_view() cannot see
            * $_settings/$_GET, so the widget flag MUST be imported by name here or
            * the .hc-widget class silently never renders. */
           $_widget,
           /* PHASE_HC_MOBILE_WIDGET — same trap: the phone nav bar is rendered from
            * inside this function, so the flag has to be imported by name or the
            * bar (and its hamburger) silently never renders. */
           $mobileWidgetView,
           /* SAME TRAP for the two optional home sections. hc_build_view() sees
            * NOTHING it does not name here, so both the settings that decide
            * whether News and Quick links render, and the two variables that
            * carry their markup out to hc_render_home(), have to be imported by
            * name — otherwise the sections silently never appear. */
           $homeNewsEnabled, $homeNewsFeed, $homeNewsLimit,
           $homeQuickEnabled, $homeQuickStyle, $homeQuickItems, $homeQuickLimit, $homeQuickDisplay,
           $hcHomeNewsHtml, $hcHomeQuickHtml, $hcHomeCtaHtml, $hcHomeCtaFullHtml,
           $homeCtaEnabled, $homeCta, $homeCtaVariant, $homeCtaSize, $homeCtaWidth,
           $homeCtaAlign, $homeCtaRadius, $homeCtaShadow, $homeCtaHeight, $homeCtaSides,
           $_motionIntensity, $_visualDepth, $_radiusStyle, $_iconStyleCls,
           
           /* PHASE1_2026-08-06 — resolved per-surface hero modes. */
           $heroModeHome, $heroModeCategory, $heroModeArticle, $heroModeSearch,
           /* PHASE2_2026-08-06 — resolved side-rail settings. */
           $sidebarCategory, $sidebarArticle, $sidebarSearch, $sidebarCatContent,
           $showStats, $showCategories, $showPopular, $showFeedback,
           $showContactPrompt, $showToc, $showRelated, $popularArticlesLimit,
           $txtHeroHeading, $txtHeroSub, $txtSearchPH, $txtBrowseLabel,
           $txtPopularLabel, $txtNoResults, $txtFeedbackQ,
           $txtContactPrompt, $txtContactUrl, $categorySidebarPosition, $readerLayout, $articleCardStyle,
           $homeLayout, $homeCategoryIcons,
           /* PHASE_HC_HOME_FEATURED — same trap: the home featured block reads these
            * inside hc_build_view(), so they MUST be imported by name here. */
           $homeFeaturedEnabled, $homeFeaturedTitle, $homeFeaturedPosition, $homeFeaturedLimit, $homeFeaturedSource,
           /* PHASE_HC_SUBCAT_STYLE — MUST be listed here: hc_build_view() cannot
            * see $_settings, so any per-view style token has to be resolved at
            * global scope and imported by name. */
           $subcategoryStyle, $subcategoryIcons, $homeCategoryLimit,
           /* PHASE_HC_WIDGET_STUDIO — the panel's "Open in help center" link. Same
            * trap: not in this list = silently never renders. */
           $widgetHelpLink, $widgetHelpLinkMode, $widgetHelpLinkUrl, $widgetHelpLinkLabel,
           /* PHASE_HC_SECTIONS — same trap, and it bit again: without these two in
            * the list, $feedbackCounts was UNDEFINED inside this function, so
            * '' !== 'hide' passed the gate while '' === 'ratio' failed, and every
            * install that chose "12 of 14 found this helpful" silently rendered the
            * bare thumbs counts instead. Any new per-view token goes here. */
           $feedbackCounts, $feedbackCountsMin,
           /* PHASE_HC_I18N_AI — the i18n chrome closure. Without it here, the hardcoded
            * labels below ("Yes, helpful", "Related articles", the "← Help Center" back
            * link) rendered English on every translated page. Catalogue keys already
            * exist, $_searchTotal, $_searchPages, $_searchPage; $__t returns the $_locale string or the English fallback. */
           $__t,
           /* PHASE_HC_I18N_AI — tag-overlay (and any i18n branch inside this fn) needs
            * these; only $_locale was imported, so $_i18nOn was undefined here and the
            * tag translation silently no-op'd. The $__t fatal's cousin. */
           $_i18nOn, $_i18nSource, $_siteKey, $_locale,
           /* PHASE_HC_I18N_AI — machine-translation disclosure. Set on the article
            * detail path from the translation row's `origin`; same import trap as
            * every other i18n var used inside this function. */
           $_articleTrMachine;
    /* PHASE_HC_WIDGET_LOOK — the Help look renders its own view; see hc_render_widget_look(). */
    if (!empty($GLOBALS['_wdVariant'])) return hc_render_widget_look();

    ob_start();
    $isCategoryPage = ($_catSlug !== '' && is_array($_category));
    /* PHASE1_2026-08-06 — one mode per surface, resolved above. Replaces the tangle
     * of $showFullHeroOnCategory / $showFullHero / $showArticleSubhero, where the
     * category test ORed the two legacy booleans (so the big hero could not be
     * switched off there) and $showCategorySubhero was a hardcoded false that made
     * the subhero's own category branch unreachable dead code. */
    $__mode = is_array($_article) ? $heroModeArticle
            : ($isCategoryPage    ? $heroModeCategory
            : ($_query !== ''     ? $heroModeSearch
            :                       $heroModeHome));

    $showFullHeroOnCategory = ($isCategoryPage && $__mode === 'full');
    $showFullHero           = ($__mode === 'full');
    $showCategorySubhero    = ($isCategoryPage && $__mode === 'subhero');
    $showArticleSubhero     = (is_array($_article) && $__mode === 'subhero');
    /* PHASE_HC_SEARCH_SUBHERO_2026-08-17 — `hero_search=subhero` is a legal, OFFERED value
     * (the Studio lists it) but the two flags above are category- and article-only, so on
     * the search surface it rendered NOTHING: a dead choice, live on this workspace
     * (hero_search='subhero', search page showed no header band). The search view is a
     * category-shaped surface for chrome purposes, so it takes the category branch. */
    $showSearchSubhero      = ($_query !== '' && !$isCategoryPage && !is_array($_article) && $__mode === 'subhero');
    $showSubpageHeroBlock   = ($showArticleSubhero || $showCategorySubhero || $showSearchSubhero);
    $showSearchBand         = ($__mode === 'search');
    $heroMarkup = '';
    if ($showFullHeroOnCategory) {
        $prevHeading = $txtHeroHeading;
        $prevSub = $txtHeroSub;
        $catName = trim((string)($_category['name'] ?? ''));
        $catDesc = trim((string)($_category['description'] ?? ''));
        $txtHeroHeading = ($catName !== '' ? $catName : $txtHeroHeading);
        if ($catDesc !== '') {
            $txtHeroSub = $catDesc;
        } elseif ($showStats) {
            $catCount = is_array($_articles) ? count($_articles) : 0;
            $txtHeroSub = $catCount . " " . $__t($catCount === 1 ? "article_in" : "articles_in", $catCount === 1 ? "article in this category" : "articles in this category");
        }
        $heroMarkup = hc_render_hero();
        $txtHeroHeading = $prevHeading;
        $txtHeroSub = $prevSub;
    } elseif ($showFullHero) {
        $heroMarkup = hc_render_hero();
    }
    ?>
<div id="hc-page" class="layout-<?= hc_esc($_layout) ?> surface-<?= hc_esc($_surfaceStyle) ?> hero-<?= hc_esc($_heroShape) ?> cards-<?= hc_esc($_cardStyle) ?> density-<?= hc_esc($_contentDensity) ?> motion-<?= hc_esc($_motionIntensity) ?> depth-<?= hc_esc($_visualDepth) ?> radius-<?= hc_esc($_radiusStyle) ?> view-<?= hc_esc($_viewType) ?> reader-<?= hc_esc($readerLayout) ?><?= $_iconStyleCls ?><?= !empty($_widget) ? ' hc-widget' : '' ?><?php
/* PHASE10K13_2026-08-12 — the sidebar column sticks unless the operator says
 * otherwise. '' is the default and the default is STICK, so only the opt-out
 * emits a class: an untouched workspace renders exactly the same markup. */
$__sideSticky = trim((string)($GLOBALS['_settings']['sidebar_sticky'] ?? ''));
echo $__sideSticky === 'scroll' ? ' hc-side-scroll' : '';
/* PHASE_HC_DS_REACH_2026-08-27 — which directory surfaces the operator has painted,
 * so hc-sheet-6 can make room for a fill on the presentations that have no box for one. */
echo hc_ds_dir_flags();
?>">
<?php if ($heroMarkup !== ''): ?>
<?= $heroMarkup ?>
<?php endif; ?>
<?php /* PHASE1_2026-08-06 — breadcrumbs sit UNDER the compact hero, per the owner:
        *"the breadcrumbs bring under not above"*. They are emitted as their own
        strip rather than injected into the hero, because each of the twenty themes
        owns its inner markup and nothing should reach inside it. */ ?>
<?php if ($showSubpageHeroBlock): ?>
<?= hc_render_subpage_hero() ?>
<?php endif; ?>
<?php /* PHASE1_2026-08-06 — "search only" mode: a slim band carrying just the search
        field, the compact page header the owner asked for. Reuses hc_search_markup()
        so autocomplete, the suggest panel and every wired behaviour are identical to
        the hero's field; only the chrome around it differs. */ ?>
<?php /* PHASE1_2026-08-06 — the breadcrumb strip. The reference help centers put the
        trail in its OWN full-width row under the header, aligned to the page shell,
        not indented inside the content column. Owner: "that's how bread crumbs should
        always be when no subhero". So it renders whenever the page has no subhero to
        carry the trail — i.e. in `search` and `none` modes — on category and article
        pages alike. When a subhero IS shown it owns the trail and this stays away. */ ?>
<?php if (!empty($showSearchBand)): ?>
<section id="hc-searchband" class="hc-searchband" aria-label="<?= hc_esc($__t('search', 'Search')) ?>">
  <?php /* autofocus is stripped: every hero search field carries it, which is right on
          the home page but wrong here — a category or article page would yank the
          viewport to the band on load and pop the mobile keyboard over the content the
          reader came for. The field is otherwise byte-identical to the hero's, so
          autocomplete, the suggest panel and the wired handlers behave the same. */ ?>
  <?php /* The band mirrors the page shell's own rail configuration so the field lands
          in the content track. $__hcNoRail is computed below for #hc-main; recompute the
          same test here because this band renders before it. */ ?>
  <?php $__sbNoRail = (($homeLayout ?? 'categories') === 'categories' && !$_article && $_catSlug === '' && $_query === ''); ?>
  <div class="hc-searchband-inner <?= $__sbNoRail ? 'hcsb-no-rail' : 'hcsb-rail-' . hc_esc($categorySidebarPosition) ?>"><?php /* PHASE10K9b — the band renders its OWN component now, not the hero's.
                 Its style is the operator's choice out of four, independent of
                 which of the twenty hero presentations the theme uses. */ ?><?= hc_searchband_markup((string)($GLOBALS['_settings']['searchband_style'] ?? '')) ?></div>
</section>
<?php endif; ?>



<main id="hc-main" class="hc-main">
  <?php
  $__hcNoRail = (($homeLayout ?? 'categories') === 'categories' && !$_article && $_catSlug === '' && $_query === '');
  /* PHASE2_2026-08-06 — per-surface rail. When the operator turns the rail off for a
   * surface, the shell must collapse to ONE column as well, or the grid keeps the
   * empty track reserved and the content sits in a narrower column for no reason.
   * (The Portal hit exactly this: "display:none does not remove a grid TRACK".) */
  $__railOff = ($_article && !$sidebarArticle)
            || ($_catSlug !== '' && $_category && !$sidebarCategory)
            || ($_query !== '' && !$sidebarSearch);
  if ($__railOff) $__hcNoRail = true;
  ?>
  <?php /* PHASE1_2026-08-06 — the CATEGORY breadcrumb, emitted before the shell.
          The article page's shell is a single 1fr column (its own .hc-art-layout makes
          the two columns), so its crumb naturally spans the page at the left edge. The
          category shell IS the two-column grid, so a crumb inside .hc-page-content is
          trapped in the content track and sits indented past the sidebar.
          Owner: "let category have its own, but place as articles reading page is
          placed" — so it renders here, outside the grid, matching that position. */ ?>
  <?php /* Every category mode gets this same trail in this same place. Only the
          subhero is excluded, because that band carries its own. Owner: "move the
          help center bread crumbs out too to above the side bar. lets not have it
          inside anymore". */ ?>
  <?php if ($_catSlug !== '' && is_array($_category) && !$_article && !$showCategorySubhero): ?>
  <nav class="hc-crumb hc-crumb-cat" aria-label="<?= hc_esc($__t("breadcrumb", "Breadcrumb")) ?>">
    <a href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t('home','Help Center')) ?></a>
    <?php foreach (hc_cat_ancestors($_category) as $__cc): ?>
      <span class="hc-crumb-sep" aria-hidden="true">›</span>
      <a href="<?= hc_esc(hc_u('cat=' . urlencode((string)($__cc['slug'] ?? '')))) ?>"><?= hc_esc((string)($__cc['name'] ?? '')) ?></a>
    <?php endforeach; ?>
    <span class="hc-crumb-sep" aria-hidden="true">›</span>
    <span><?= hc_esc((string)$_category['name']) ?></span>
  </nav>
  <?php endif; ?>
  <div class="hc-page-shell hc-rail-<?= hc_esc($categorySidebarPosition) ?><?= $__hcNoRail ? ' hc-no-rail' : '' ?>">
    <div class="hc-page-content">
<?php
/* PHASE_HC_WIDGET — panel navigation bar. In the 420px panel the side rail cannot
 * sit beside the content, so the responsive grid stacked it ABOVE the articles and
 * you had to scroll past the whole category list to reach them. In widget mode the
 * rail is pulled out of the flow (CSS) and reached from this bar instead: a back
 * link on the left, a hamburger on the right that slides the rail in as a sheet.
 * The bar renders only in widget mode; the public page and ?embed=1 never see it.
 *
 * PHASE_HC_MOBILE_WIDGET — a phone has the same problem as the 420px panel, so the
 * bar now also renders on the PUBLIC category/article pages when the mobile widget
 * layout is on. There it is hidden above 820px by CSS, and its hamburger drives the
 * rail as a slide-in sheet WITHOUT moving the rail in the DOM (see hcInitMobileNav) —
 * the widget's adopt-the-node approach would strip the rail off the desktop page. */
$__wBarMobile = empty($_widget) && !empty($mobileWidgetView);
if (!empty($_widget) || $__wBarMobile):
    $__wIsHome  = (!$_article && $_catSlug === '' && $_query === '');
    $__wBackUrl = hc_u();
    $__wBackLbl = $siteName !== '' ? $siteName : 'Help Center';
    if ($_article && $_category) {
        /* Reading an article: back goes to the category it lives in. */
        $__wBackUrl = hc_u('cat=' . urlencode((string)($_category['slug'] ?? '')));
        $__wBackLbl = (string)($_category['name'] ?? 'Help Center');
    } elseif ($_category) {
        /* On a nested category, back climbs to the immediate parent — the same
         * trail the page's own back link walks, which this bar replaces. */
        $__wAnc = hc_cat_ancestors($_category);
        if ($__wAnc) {
            $__wParent  = end($__wAnc);
            $__wBackUrl = hc_u('cat=' . urlencode((string)($__wParent['slug'] ?? '')));
            $__wBackLbl = (string)($__wParent['name'] ?? $__wBackLbl);
        }
    }
?>
      <?php /* Home needs neither: the tiles ARE the index, so a back link would point
               at the page you are on and the hamburger would repeat it. */
            if (!$__wIsHome): ?>
      <div class="hc-w-bar<?= $__wBarMobile ? ' hc-w-bar-mobile' : '' ?>">
        <a class="hc-w-back" href="<?= hc_esc($__wBackUrl) ?>">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
          <span><?= hc_esc(mb_strimwidth($__wBackLbl, 0, 30, '…')) ?></span>
        </a>
        <button type="button" class="hc-w-menu" id="hc-w-menu" aria-expanded="false" aria-controls="hc-w-sheet" aria-label="<?= hc_esc($__t("browse_all", "Browse")) ?>" hidden>
          <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
        </button>
      </div>
      <?php endif; ?>
<?php endif; ?>

<?php if ($_article): ?>
<!-- Article view -->
  <?php /* PHASE1_2026-08-06 — gate on the RESOLVED mode, not the legacy flag: the
          band now carries its own breadcrumb, so this one would be a duplicate. */ ?>
  <?php /* The article page's own breadcrumb — RESTORED to exactly what it was.
          A previous pass replaced it with a full-width strip; the owner's call is that
          this one is correct, and that a category page in search-only mode should use
          the very same treatment rather than a second design. */ ?>
  <?php if (!$showSubpageHeroBlock): ?>
  <nav class="hc-crumb" aria-label="<?= hc_esc($__t("breadcrumb", "Breadcrumb")) ?>">
    <a href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t('home','Help Center')) ?></a>
    <?php if ($_category): ?>
    <span class="hc-crumb-sep" aria-hidden="true">›</span>
    <a href="<?= hc_esc(hc_u('cat=' . urlencode((string)$_category['slug']))) ?>"><?= hc_esc((string)$_category['name']) ?></a>
    <?php endif; ?>
    <span class="hc-crumb-sep" aria-hidden="true">›</span>
    <span><?= hc_esc(mb_strimwidth((string)($_article['page_title'] ?? ''), 0, 48, '…')) ?></span>
  </nav>
  <?php endif; ?>

  <?php /* PHASE10K11_2026-08-11 — with the rail off there is no aside to fill the
           rail track, so the article card fell INTO it and rendered at 300px: a
           reading column narrower than the sidebar it replaced. The layout says
           so in a class, and the grid collapses to one column. */ ?>
  <div class="hc-art-layout hc-art-rail-<?= hc_esc($categorySidebarPosition) ?> hc-reader-layout-<?= hc_esc($readerLayout) ?><?= empty($sidebarArticle) ? ' hc-art-no-rail' : '' ?>">
      <?php
      /* PHASE4_2026-08-06 — reading progress.
       *
       * Measured against the ARTICLE BODY, not the document. A help centre page
       * carries a rail, related articles, a feedback block and a footer; a bar
       * tracking document scroll would read 60% while the reader is still on the
       * first paragraph, which is worse than no bar at all.
       *
       * Off by default: it is a persistent element in the viewport and that is a
       * visual decision, not a fix. */
      /* PHASE10K_2026-08-11 — the FIFTH hit of the unimported-$_settings trap
       * (see the hc-fs-* and discussions notes below): hc_build_view() never imports
       * $_settings, so the TWELVE reader controls below read null and answered their
       * fallbacks forever — progress/updated/views/prev-next/CTA could never turn ON,
       * actions/code-copy could never turn OFF, the measure presets were dead. The
       * whole Article reader sheet, minus the layout dropdown, was decorative.
       * House fix per the note below: resolve $GLOBALS once, never touch the shared
       * import list. */
      $__rdCfg = isset($GLOBALS["_settings"]) && is_array($GLOBALS["_settings"]) ? $GLOBALS["_settings"] : [];
      $__rpOn = !in_array(strtolower((string)($__rdCfg['reader_progress'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
      if ($__rpOn): ?>
      <div class="hc-read-progress" id="hc-read-progress" role="presentation" aria-hidden="true"></div>
      <script>
      (function(){
        if (window.__hcProgBound) return;
        window.__hcProgBound = true;
        var bar, body, ticking = false;
        function pick(){ bar = document.getElementById('hc-read-progress'); body = document.getElementById('hc-article-body'); }
        function draw(){
          ticking = false;
          if (!bar || !body) return;
          var r = body.getBoundingClientRect();
          var vh = window.innerHeight || document.documentElement.clientHeight;
          /* how far the reader is THROUGH the body, clamped both ends */
          var total = r.height - vh;
          var pct = total <= 0 ? (r.bottom <= vh ? 1 : 0) : (-r.top) / total;
          pct = Math.max(0, Math.min(1, pct));
          bar.style.width = (pct * 100).toFixed(2) + '%';
        }
        function onScroll(){ if (!ticking) { ticking = true; requestAnimationFrame(draw); } }
        pick();
        addEventListener('scroll', onScroll, { passive: true });
        addEventListener('resize', onScroll, { passive: true });
        /* the Help Center soft-navigates, so the element this points at is replaced
           without a page load; re-pick on any history change */
        addEventListener('popstate', function(){ setTimeout(function(){ pick(); draw(); }, 60); });
        draw();
      })();
      </script>
      <?php endif; ?>
    <article class="hc-art-card hc-reader-card-<?= hc_esc($readerLayout) ?>">
      <header>
        <div class="hc-art-meta">
          <?php if ($_category): ?>
          <a class="hc-art-cat-chip" href="<?= hc_esc(hc_u('cat=' . urlencode((string)$_category['slug']))) ?>">
            <?php if (!empty($_category['icon'])): ?><span class="hc-chip-ico" aria-hidden="true"><?= hc_cat_icon_html((string)$_category['icon']) ?></span><?php endif; ?>
            <?= hc_esc((string)$_category['name']) ?>
          </a>
          <?php endif; ?>
          <?php
          /* PHASE4_2026-08-06 — last updated. The column has been on every row since
           * the table was created and had never been rendered anywhere in this file.
           * Off by default: a visible date on an article nobody has touched in two
           * years is a trust signal pointing the wrong way, and that is the
           * operator's call. */
          $__ruOn = !in_array(strtolower((string)($__rdCfg['reader_updated'] ?? '')), ['','0','off','false','no'], true);
          $__upd = trim((string)($_article['updated_at'] ?? ''));
          $__updTs = $__upd !== '' ? strtotime($__upd) : 0;
          if ($__ruOn && $__updTs > 0): ?>
          <span class="hc-art-upd">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
            <?php /* a machine-readable datetime alongside the human one, so the date
                     is unambiguous to anything parsing the page */ ?>
            <time datetime="<?= hc_esc(date('c', $__updTs)) ?>"><?= hc_esc(hc_date_local($__updTs)) ?></time>
          </span>
          <?php endif; ?>
          <?php
          /* PHASE4_2026-08-06 — views. views_count is on every row and was only ever
           * used on listing cards, never on the article itself. */
          $__rvOn = !in_array(strtolower((string)($__rdCfg['reader_views'] ?? '')), ['','0','off','false','no'], true);
          $__views = (int)($_article['views_count'] ?? 0);
          if ($__rvOn && $__views > 0): ?>
          <span class="hc-art-views">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
            <?= number_format($__views) ?>
          </span>
          <?php endif; ?>
          <?php
          /* PHASE_HC_LIFECYCLE_2026-08-17 — "Reviewed on <date>". The date an operator last
           * confirmed the article is still correct (set on every manual edit, or from the
           * Content review queue). Deliberately a POSITIVE signal, in keeping with the
           * freshness rule below: we tell readers when something was checked, never that it
           * is old. Off by default; only renders when a review has actually happened. */
          $__rvOn = !in_array(strtolower((string)($__rdCfg['reader_reviewed'] ?? '')), ['','0','off','false','no'], true);
          $__rv   = trim((string)($_article['reviewed_at'] ?? ''));
          $__rvTs = $__rv !== '' ? strtotime($__rv) : 0;
          if ($__rvOn && $__rvTs > 0): ?>
          <span class="hc-art-reviewed" title="<?= hc_esc($__t('reviewed_on_title', 'An editor confirmed this article is still accurate on this date')) ?>">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
            <?= hc_esc($__t('reviewed_on', 'Reviewed')) ?> <time datetime="<?= hc_esc(date('c', $__rvTs)) ?>"><?= hc_esc(hc_date_local($__rvTs)) ?></time>
          </span>
          <?php endif; ?>
          <?php
          /* Freshness. Marks RECENCY, never staleness: a badge announcing that an
           * article is old is a reason not to trust it, published by the people who
           * wrote it. 0 turns it off. Reuses $__updTs, already resolved above, so the
           * badge works whether or not the date itself is shown. */
          $__freshDays = max(0, min(730, (int)($__rdCfg['reader_freshness_days'] ?? 0)));
          if ($__freshDays > 0 && $__updTs > 0 && (time() - $__updTs) <= ($__freshDays * 86400)): ?>
          <span class="hc-art-fresh">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
            <?= hc_esc($__t('recently_updated', 'Recently updated')) ?>
          </span>
          <?php endif; ?>
          <?php /* PHASE_HC_ACCESS — no read time on a gated page: it describes a body the reader can't see. */ ?>
          <?php if ($_readTime > 0 && \OpsIQ\Kb\HelpCenter::canReadArticle($_article)): ?>
          <span class="hc-art-rtime">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
            <?= $_readTime ?> <?= hc_esc($__t('min_read', 'min read')) ?>
          </span>
          <?php endif; ?>
        </div>
        <h1 class="hc-art-title"><?= hc_esc((string)($_article['page_title'] ?? '')) ?></h1>
        <?php hc_cta_at('article', 'after_title'); ?>
        <div class="hc-art-div"></div>
        <?php
        /* PHASE4_2026-08-06 — the reader actions row: copy link, share, print.
         *
         * Owner: "add copy, print, share icons". All four strings already existed
         * (act_copy / act_copied / act_share / act_print), so nothing new needed
         * translating for this.
         *
         * Print is a <button> because it performs an action on this page. Copy and
         * share are too. None of them navigates, so none of them is a link — the
         * inverse of the help card's button, which IS a link because it goes
         * somewhere. */
        $__raOn = !in_array(strtolower((string)($__rdCfg['reader_actions'] ?? '1')), ['', '0', 'off', 'false', 'no'], true);
        $__raStyle = (string)($__rdCfg['reader_actions_style'] ?? 'both');
        if (!in_array($__raStyle, ['icons', 'labels', 'both'], true)) $__raStyle = 'both';
        if ($__raOn):
        ?>
        <div class="hc-art-actions hc-art-actions-<?= hc_esc($__raStyle) ?>">
          <button type="button" class="hc-art-act" data-hc-act="copy"
                  data-copied="<?= hc_esc($__t('act_copied', 'Copied')) ?>">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
            <span><?= hc_esc($__t('act_copy', 'Copy link')) ?></span>
          </button>
          <button type="button" class="hc-art-act" data-hc-act="share">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
            <span><?= hc_esc($__t('act_share', 'Share')) ?></span>
          </button>
          <button type="button" class="hc-art-act" data-hc-act="print">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 14h12v8H6z"/></svg>
            <span><?= hc_esc($__t('act_print', 'Print')) ?></span>
          </button>
        </div>
        <script>
        /* Delegated and bound once, for the same reason the directory toggle is:
           the Help Center soft-navigates, and a per-element listener bound on load
           stops working after the first navigation.

           navigator.share is used when the browser HAS it (a phone gets the real
           share sheet); everything else falls back to copying the URL, which is
           what a share button is actually for on a desktop. Both paths need a user
           gesture, which a click already is. */
        (function(){
          if (window.__hcActBound) return;
          window.__hcActBound = true;
          document.addEventListener('click', function(e){
            var b = e.target.closest && e.target.closest('.hc-art-act');
            if (!b) return;
            var act = b.getAttribute('data-hc-act');
            if (act === 'print') { window.print(); return; }
            var url = location.href;
            if (act === 'share' && navigator.share) {
              navigator.share({ title: document.title, url: url }).catch(function(){});
              return;
            }
            var done = function(){
              var lab = b.querySelector('span');
              if (!lab || b.dataset.busy) return;
              b.dataset.busy = '1';
              var was = lab.textContent;
              lab.textContent = b.getAttribute('data-copied') || 'Copied';
              b.classList.add('is-done');
              setTimeout(function(){ lab.textContent = was; b.classList.remove('is-done'); delete b.dataset.busy; }, 1600);
            };
            /* The clipboard API being PRESENT does not mean it will succeed. It
               rejects outside a secure context, when permission is denied, and when
               the call is not tied to a real user gesture. The first version only
               fell back when the API was ABSENT, so in every rejecting case the
               button did nothing at all and gave no feedback — verified in Chrome.
               Failure now falls through to the textarea path. */
            var legacy = function(){
              var t = document.createElement('textarea');
              t.value = url; t.setAttribute('readonly','');
              t.style.cssText = 'position:absolute;left:-9999px';
              document.body.appendChild(t); t.select();
              var ok = false;
              try { ok = document.execCommand('copy'); } catch(err){}
              document.body.removeChild(t);
              /* Feedback either way. A button that reports nothing is worse than one
                 that reports optimistically, and the user can see their own clipboard. */
              done();
              return ok;
            };
            if (navigator.clipboard && navigator.clipboard.writeText) {
              navigator.clipboard.writeText(url).then(done).catch(legacy);
            } else {
              legacy();
            }
          });
        })();
        </script>
        <?php endif; ?>
      </header>
      <?php /* PHASE_HC_I18N_AI — machine-translation disclosure. Shown ONLY when this
               page is actually serving a machine-produced translation: a row an
               operator post-edited is origin='human' (and is never overwritten by the
               runner), so it carries no notice. Source-language pages never reach
               here because $_articleTrMachine is only set by the overlay. */
        if (!empty($_articleTrMachine)): ?>
      <p class="hc-mt-note" role="note">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
        <?= hc_esc($__t('mt_disclosure', 'This article was translated automatically.')) ?>
      </p>
      <?php endif; ?>
      <?php
      /* PHASE4_2026-08-06 — reading measure. '' leaves the body exactly as it is
       * (no max-width), which is what every existing site runs on. */
      $__rm = (string)($__rdCfg['reader_measure'] ?? '');
      if (!in_array($__rm, ['narrow', 'normal', 'wide'], true)) $__rm = '';
      ?>
      <?php hc_cta_at('article', 'before_content'); ?>
      <div class="hc-body<?= $__rm !== '' ? ' hc-body-m-' . hc_esc($__rm) : '' ?>" id="hc-article-body">
        <?php /* PHASE_HC_ARTICLE_FORMAT — prefer the AI-tidied body; fall back to
                 the raw crawl chunk. Both go through the same sanitiser. */
          $__fmt = trim((string)($_article['content_formatted'] ?? ''));
        ?>
        <?php /* PHASE10K8 — the in-body placements (after the intro, after the Nth
                 paragraph, after N% of the article) are injected into the SANITISED
                 body, never the raw one: the band is markup we own being placed
                 between blocks the sanitiser has already approved. */ ?>
        <?php if (!\OpsIQ\Kb\HelpCenter::canReadArticle($_article)):
            /* PHASE_HC_ACCESS_2026-08-17 — a customers-only article for a signed-out reader.
             * The title, excerpt and metadata above still render (discovery), the BODY does
             * not, and the reader is told exactly what to do. The sign-in target is the
             * portal's own entry, the same one custom chrome uses via {portal_login_url}. */
            /* opsiq_portal_public_base() lives in opsiq.portal_experience.php, which a plain HC
             * page does not load — so this (and custom chrome's {portal_login_url}) resolved to
             * '' and the button vanished. Same fix shape the design file already uses above. */
            if (!function_exists('opsiq_portal_public_base') && is_file($_opsiqRoot . '/opsiq/opsiq.portal_experience.php')) {
                try { require_once $_opsiqRoot . '/opsiq/opsiq.portal_experience.php'; } catch (\Throwable $e) {}
            }
            $__gateBase = function_exists('opsiq_portal_public_base')
                ? (string)opsiq_portal_public_base((string)($GLOBALS['_siteKey'] ?? ''))
                : '';
            $__gateUrl  = $__gateBase !== '' ? hc_portal_login_url() : '';
            /* No portal address on this workspace (an honest '' by design) → send the reader
             * where the operator already sends people to sign in: the header's own login CTA
             * (nav_ctas first, then the scalar). Never invent a host. */
            if ($__gateUrl === '') {
                $__gs = (isset($GLOBALS['_settings']) && is_array($GLOBALS['_settings'])) ? $GLOBALS['_settings'] : [];   // hc_build_view() does not import $_settings
                $__navCtaList = json_decode((string)($__gs['nav_ctas'] ?? ''), true);
                if (is_array($__navCtaList)) foreach ($__navCtaList as $__c) { if (is_array($__c) && preg_match('/log ?in|sign ?in|connexion|anmelden|acceder|entrar/i', (string)($__c['label'] ?? '')) && trim((string)($__c['url'] ?? '')) !== '') { $__gateUrl = trim((string)$__c['url']); break; } }
                if ($__gateUrl === '') $__gateUrl = trim((string)($__gs['nav_cta_url'] ?? ''));
            }
        ?>
        <div class="hc-gate" role="region" aria-label="<?= hc_esc($__t('gate_title', 'Customers only')) ?>">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
          <div class="hc-gate-copy">
            <strong><?= hc_esc($__t('gate_title', 'Customers only')) ?></strong>
            <p><?= hc_esc($__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.')) ?></p>
          </div>
          <?php if ($__gateUrl !== ''): ?><a class="hc-gate-btn" href="<?= hc_esc($__gateUrl) ?>"><?= hc_esc($__t('gate_signin', 'Sign in')) ?></a><?php endif; ?>
        </div>
        <?php else: ?>
        <?= hc_cta_inject_body(hc_article_body($__fmt !== "" ? $__fmt : (string)($_article["content_text"] ?? ""), (string)($_article["page_title"] ?? "")), 'article') ?>
        <?php endif; ?>
      </div>

      <?php
      /* PHASE4_2026-08-06 — previous / next within the category.
       *
       * Built from $_articles, the sibling list the rail already renders, so this
       * costs no extra query. That list is ordered by views then id, NOT by any
       * authored sequence — so "previous" and "next" mean "adjacent in the list
       * the reader can see in the rail", which is the only ordering that exists.
       * Calling them Previous/Next is honest against that rail; it would not be
       * against an imagined curriculum order.
       *
       * Off by default. On a category of 50 articles sorted by popularity, the
       * neighbours are arbitrary to a reader who arrived by search, so whether it
       * helps depends on how the operator's content is organised. */
      $__pnOn = !in_array(strtolower((string)($__rdCfg['reader_prevnext'] ?? '')), ['', '0', 'off', 'false', 'no'], true);
      /* PHASE10K_2026-08-11 — $_articles stopped being populated on the article view
       * when the rail moved to hc_category_articles_rail() (which fetches its own
       * siblings by category id), so this gate starved and the block never rendered
       * ANYWHERE. Fetch the same list the rail shows, the same way, when the global
       * is empty — "adjacent in the list the reader can see" stays true. */
      $__pnPool = is_array($_articles ?? null) ? array_values($_articles) : [];
      if ($__pnOn && count($__pnPool) < 2 && $_category && (int)($_category['id'] ?? 0) > 0) {
          try { $__pnPool = array_values((array)HelpCenter::listPublicArticles($GLOBALS['_siteKey'], (int)$_category['id'], 200, 0, true, (string)($GLOBALS['_locale'] ?? ''))); }
          catch (\Throwable $e) { $__pnPool = []; }
      }
      /* PHASE_HC_LISTING_I18N_2026-08-15 — prev/next printed ENGLISH neighbour titles on a
       * fully French article page ("How to Check the Disk Usage…" under "Précédent"). Both
       * pools arrive raw: $_articles is whatever the rail left behind, and the fallback
       * fetch above goes straight to HelpCenter. There is no automatic overlay inside
       * listPublicArticles(), so every surface must ask — this one never did. */
      if ($__pnOn && $__pnPool) {
          try {
              $__pnLoc = (string)($GLOBALS['_locale'] ?? '');
              if ($__pnLoc !== '' && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
                  \OpsIQ\Kb\HcTranslator::overlayArticles($__pnPool, (string)$GLOBALS['_siteKey'], $__pnLoc);
              }
          } catch (\Throwable $e) { /* English neighbours beat no neighbours */ }
      }
      if ($__pnOn && count($__pnPool) > 1 && $_articleSlug !== ''):
          $__sibs = $__pnPool;
          $__at = -1;
          foreach ($__sibs as $__ix => $__s) {
              if (trim((string)($__s['slug'] ?? '')) === $_articleSlug) { $__at = $__ix; break; }
          }
          /* If the current article is not IN the sibling list, there is no "adjacent"
             to speak of and the block renders nothing rather than guessing. */
          if ($__at >= 0):
              $__prev = $__at > 0 ? $__sibs[$__at - 1] : null;
              $__next = $__at < count($__sibs) - 1 ? $__sibs[$__at + 1] : null;
              if ($__prev || $__next):
      ?>
      <?php /* PHASE10K8 — "before related" sits ahead of the more-in-this-category
               block, which is the related reading on this page. */ ?>
      <?php hc_cta_at('article', 'before_nav'); ?>
      <nav class="hc-art-nav" aria-label="<?= hc_esc($__t('nav_within_category', 'More in this category')) ?>">
        <?php if ($__prev): ?>
          <a class="hc-art-nav-l" href="<?= hc_esc(hc_u('article=' . rawurlencode((string)$__prev['slug']))) ?>" rel="prev">
            <span class="hc-art-nav-k">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 18l-6-6 6-6"/></svg>
              <?= hc_esc($__t('nav_prev', 'Previous')) ?>
            </span>
            <span class="hc-art-nav-t"><?= hc_esc((string)($__prev['page_title'] ?? '')) ?></span>
          </a>
        <?php else: ?><span></span><?php endif; ?>

        <?php if ($__next): ?>
          <a class="hc-art-nav-r" href="<?= hc_esc(hc_u('article=' . rawurlencode((string)$__next['slug']))) ?>" rel="next">
            <span class="hc-art-nav-k">
              <?= hc_esc($__t('nav_next', 'Next')) ?>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
            </span>
            <span class="hc-art-nav-t"><?= hc_esc((string)($__next['page_title'] ?? '')) ?></span>
          </a>
        <?php endif; ?>
      </nav>
      <?php     endif;
          endif;
      endif; ?>
      <?php hc_cta_at('article', 'after_nav'); ?>

      <?php
      /* PHASE4_2026-08-07 — a copy button on each code block.
       *
       * Added by script rather than by the author, because the bodies come from a
       * crawl and an AI tidy — nobody is hand-writing a button into them. 59
       * articles carry <pre>.
       *
       * Delegated and bound once, like the reader actions and the directory
       * toggle: the Help Center soft-navigates and a per-element listener bound on
       * load stops working after the first navigation. */
      $__ccOn = !in_array(strtolower((string)($__rdCfg['reader_code_copy'] ?? '1')), ['', '0', 'off', 'false', 'no'], true);
      if ($__ccOn): ?>
      <script>
      (function(){
        if (window.__hcCodeCopyBound) return;
        window.__hcCodeCopyBound = true;
        var LABEL = <?= json_encode($__t('act_copy', 'Copy link') === 'Copy link' ? 'Copy' : $__t('act_copy', 'Copy')) ?>;
        var DONE  = <?= json_encode($__t('act_copied', 'Copied')) ?>;
        function decorate(){
          var body = document.getElementById('hc-article-body');
          if (!body) return;
          body.querySelectorAll('pre').forEach(function(pre){
            if (pre.querySelector('.hc-code-copy')) return;
            var b = document.createElement('button');
            b.type = 'button';
            b.className = 'hc-code-copy';
            b.textContent = LABEL;
            /* the button lives INSIDE <pre>, so its own text would otherwise be
               copied along with the code */
            b.setAttribute('data-hc-skip', '1');
            pre.appendChild(b);
          });
        }
        document.addEventListener('click', function(e){
          var b = e.target.closest && e.target.closest('.hc-code-copy');
          if (!b) return;
          var pre = b.closest('pre');
          if (!pre) return;
          var clone = pre.cloneNode(true);
          clone.querySelectorAll('[data-hc-skip]').forEach(function(n){ n.remove(); });
          var text = clone.textContent.replace(/\s+$/, '');
          var done = function(){
            if (b.dataset.busy) return;
            b.dataset.busy = '1';
            b.textContent = DONE;
            b.classList.add('is-done');
            setTimeout(function(){ b.textContent = LABEL; b.classList.remove('is-done'); delete b.dataset.busy; }, 1600);
          };
          var legacy = function(){
            var t = document.createElement('textarea');
            t.value = text; t.setAttribute('readonly','');
            t.style.cssText = 'position:absolute;left:-9999px';
            document.body.appendChild(t); t.select();
            try { document.execCommand('copy'); } catch(err){}
            document.body.removeChild(t);
            done();
          };
          /* the clipboard API rejects outside a secure context and when permission
             is denied — presence is not success, which the reader actions already
             learned the hard way */
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).then(done).catch(legacy);
          } else { legacy(); }
        });
        decorate();
        addEventListener('popstate', function(){ setTimeout(decorate, 60); });
      })();
      </script>
      <?php endif; ?>

      <?php /* PHASE10K8_2026-08-11 — the end-of-article placement. This used to be
               a CTA of its own, assembled here from four flat keys; it is now one
               placement of the ONE component, chosen in the CTA card like every
               other. The older keys still work: they are folded into the same
               model at load, so nothing switched itself off. */ ?>
      <?php hc_cta_at('article', 'after_content'); ?>

      <?php
      /* PHASE_HELP_ORGANIZER — tag chips (AI-assigned). Clicking a tag searches
       * for it, so visitors can pivot to related articles. */
      $_artTags = array_values(array_filter(array_map('trim', explode(',', (string)($_article['help_tags'] ?? ''))), static fn($t) => $t !== ''));
      $_artTags = array_slice($_artTags, 0, 10);
      if (!empty($_artTags)): ?>
      <?php /* PHASE_HC_I18N_AI — DISPLAY the translated tag, but SEARCH the original:
               the search index is the source language, so a translated query matches
               nothing. Falls back to the original tag when no translation exists. */
        $_tagLoc = (string)($_locale ?? '');
        $_tagTr = (!empty($_i18nOn) && $_tagLoc !== '' && $_tagLoc !== (string)($_i18nSource ?? '') && (string)($_siteKey ?? '') !== '' && class_exists('\\OpsIQ\\Kb\\HcTranslator'))
                ? \OpsIQ\Kb\HcTranslator::tagsOverlay((string)$_siteKey, $_tagLoc) : []; ?>
      <div class="hc-art-tags" aria-label="<?= hc_esc($__t("tags", "Tags")) ?>">
        <?php foreach ($_artTags as $_tag): $_tagShow = $_tagTr[mb_strtolower($_tag)] ?? $_tag; ?>
        <a class="hc-art-tag" href="<?= hc_esc(hc_u('q=' . urlencode($_tag))) ?>"># <?= hc_esc($_tagShow) ?></a>
        <?php endforeach; ?>
      </div>
      <?php endif; ?>

      <?php if ($showFeedback): ?>
      <div class="hc-feedback" id="hc-feedback-<?= (int)$_articleId ?>">
        <p class="hc-fb-q"><?= hc_esc($txtFeedbackQ) ?></p>
        <div class="hc-fb-btns">
          <button class="hc-fb-btn yes" type="button" onclick="hcFeedback(<?= (int)$_articleId ?>,true)">
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
            <?= hc_esc($__t('feedback_yes','Yes, helpful')) ?>
          </button>
          <button class="hc-fb-btn no" type="button" onclick="hcFeedback(<?= (int)$_articleId ?>,false)">
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/><path d="M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
            <?= hc_esc($__t('feedback_no','Not really')) ?>
          </button>
        </div>
        <?php
        /* PHASE_HC_SECTIONS — the like/dislike display, configurable.
         *
         *   hide   (default) — the buttons only. What many teams do:
         *                      the counts stay in your analytics.
         *   ratio            — "12 of 14 found this helpful". The most common and
         *                      the common convention. The dislike count is never
         *                      shown as a bare number, because that is social
         *                      proof against your own article.
         *   counts           — 👍 12  👎 2. Fully transparent, and the one thing
         *                      most enterprise help centers deliberately avoid.
         *
         * A minimum vote count gates all of it, so a fresh article never renders
         * the infamous "0 out of 0 found this helpful". */
        $__up   = (int)($_article['helpful_count'] ?? 0);
        $__down = (int)($_article['unhelpful_count'] ?? 0);
        $__tot  = $__up + $__down;
        if ($feedbackCounts !== 'hide' && $__tot >= max(1, $feedbackCountsMin)):
        ?>
        <div class="hc-fb-counts">
          <?php if ($feedbackCounts === 'ratio'): ?>
<?php /* PHASE_HC_I18N — {n}/{m} carry the bold numbers into the translated sentence. */
                  echo str_replace(['{n}', '{m}'],
                    ['<strong>' . (int)$__up . '</strong>', '<strong>' . (int)$__tot . '</strong>'],
                    hc_esc($__t('found_helpful_ratio', '{n} of {m} found this helpful'))); ?>
          <?php else: ?>
            <span class="hc-fb-count-up">👍 <?= $__up ?></span>
            <span class="hc-fb-count-down">👎 <?= $__down ?></span>
          <?php endif; ?>
        </div>
        <?php endif; ?>
      </div>
      <?php endif; ?>

      <?php /* PHASE10K11_2026-08-11 — RELATED ARTICLES FALL BACK INTO THE COLUMN.
               With the article rail switched off there is no sidebar to hold them,
               and the section used to vanish with it. It renders here instead,
               directly after the rating, which is where a reader who has just
               answered "was this helpful" is looking for somewhere to go next.
               Same builder as the rail: same rule, same wording, same styling. */ ?>
      <?php if (empty($sidebarArticle)) echo hc_related_markup('column'); ?>

      <?php
      /* PHASE_HC_WIDGET_STUDIO — "Open in help center", the panel's way out to the
       * full page. Sits just above the contact prompt: read the article, open it
       * properly, or ask a human — in that order. Panel only; the public page and
       * ?embed=1 already ARE the help center. */
      if (!empty($_widget) && $widgetHelpLink):
        $__whUrl = hc_widget_help_url((string)($_article['slug'] ?? ''));
        if ($__whUrl !== ''):
      ?>
      <div class="hc-w-ext">
        <a class="hc-w-ext-link" href="<?= hc_esc($__whUrl) ?>" target="_blank" rel="noopener noreferrer">
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
          <span><?= hc_esc($widgetHelpLinkLabel) ?></span>
        </a>
      </div>
      <?php endif; endif; ?>

      <?php if ($showContactPrompt && $txtContactPrompt !== ''): ?>
      <div class="hc-contact-prompt">
        <?php if ($txtContactUrl !== ''): ?>
        <a href="<?= hc_esc($txtContactUrl) ?>" target="_blank" rel="noopener noreferrer"><?= hc_esc($txtContactPrompt) ?></a>
        <?php else: ?>
        <span><?= hc_esc($txtContactPrompt) ?></span>
        <?php endif; ?>
      </div>
      <?php endif; ?>

      <?php
      /* PHASE6_2026-08-07 — ARTICLE DISCUSSIONS.
       *
       * The shell only. The thread is fetched over the same-origin adapter after
       * paint, for three reasons: a discussion must never delay the article, a
       * cached/proxied page must not bake one reader's signed-in state into HTML
       * another reader receives, and the count is the one thing on this page that
       * genuinely changes minute to minute.
       *
       * NOT rendered in the widget panel — a 420px column is not where anyone reads
       * a conversation, and the panel has its own way out to the full page.
       *
       * $_settings IS NOT IN hc_build_view()'s GLOBAL IMPORT LIST — see the note at
       * the `hc-fs-*` block further down, which hit this first. An unimported global
       * is null here, so every `$_settings[...] ?? ''` quietly answers '' and the
       * toggle reads as OFF forever. Nothing errors; the section simply never exists.
       * Read $GLOBALS explicitly rather than adding to the import list, because the
       * list is shared by every branch in this 700-line function and appending to it
       * has broken unrelated blocks before. */
      $__dcCfg  = isset($GLOBALS['_settings']) && is_array($GLOBALS['_settings']) ? $GLOBALS['_settings'] : [];
      $__dcShow = !$_widget
          && !in_array(strtolower(trim((string)($__dcCfg['hc_comments_enabled'] ?? ''))), ['', '0', 'off', 'false', 'no'], true);
      if ($__dcShow):
          $__dcStyle = strtolower((string)($__dcCfg['hc_comments_style'] ?? 'cards'));
          if (!in_array($__dcStyle, ['flat', 'cards', 'threaded', 'bubbles', 'timeline'], true)) $__dcStyle = 'cards';
          $__dcHeading = trim((string)($__dcCfg['hc_comments_title'] ?? ''));
          if ($__dcHeading === '') $__dcHeading = $__t('comments_title', 'Discussion');
          $__dcPrompt = trim((string)($__dcCfg['hc_comments_signin_prompt'] ?? ''));
          if ($__dcPrompt === '') $__dcPrompt = $__t('comments_signin', 'Sign in to join the discussion.');
      ?>
      <section class="hc-disc hc-disc-<?= hc_esc($__dcStyle) ?>"
               id="hc-disc"
               data-article="<?= (int)$_articleId ?>"
               aria-labelledby="hc-disc-h">
        <div class="hc-disc-head">
          <h2 class="hc-disc-title" id="hc-disc-h"><?= hc_esc($__dcHeading) ?></h2>
          <span class="hc-disc-count" id="hc-disc-count" hidden></span>
        </div>
        <?php /* Server-rendered so a reader with JS disabled, or a slow thread, still
                 sees why the area is empty rather than an unexplained blank band. */ ?>
        <div class="hc-disc-body" id="hc-disc-body">
          <p class="hc-disc-loading"><?= hc_esc($__t('comments_loading', 'Loading the discussion…')) ?></p>
        </div>
        <div class="hc-disc-foot" id="hc-disc-foot" hidden>
          <p class="hc-disc-signin" id="hc-disc-signin"><?= hc_esc($__dcPrompt) ?></p>
        </div>
      </section>
      <?php endif; ?>
    </article>

    <?php /* PHASE2_2026-08-06 — the article rail is now optional. */ ?>
    <?php if ($sidebarArticle): ?>
    <?php /* PHASE10K7_2026-08-11 — both sidebar card titles are operator text now,
             editable in Text & Labels like every other label. Blank keeps the
             translated default, so the 40 locales are untouched until the operator
             deliberately overrides the wording. Resolved HERE, above both cards:
             hc_build_view() never imports $_settings (5th time this has bitten),
             and a resolver inside the TOC block would leave the related card
             blank whenever the TOC is switched off. */
      $__k7Cfg    = isset($GLOBALS['_settings']) && is_array($GLOBALS['_settings']) ? $GLOBALS['_settings'] : [];
      $__tocLabel = trim((string)($__k7Cfg['text_toc_label'] ?? ''));
    ?>
    <aside class="hc-sidebar" aria-label="<?= hc_esc($__t("article_navigation", "Article navigation")) ?>">
      <?php if ($showToc): ?>
      <div class="hc-side-card" id="hc-toc-card">
        <div class="hc-side-title hc-side-title-toc"><?= hc_esc($__tocLabel !== '' ? $__tocLabel : $__t("on_this_page", "On this page")) ?></div>
        <ul class="hc-toc" id="hc-toc-list" role="list"></ul>
      </div>
      <?php endif; ?>

      <?php /* PHASE_HC_HOME_LAYOUT — in categories mode the article side-nav lists sibling articles in this category. */
        if (($homeLayout ?? 'categories') === 'categories' && $_category) echo hc_category_articles_rail((int)($_category['id'] ?? 0), (string)($_category['name'] ?? ''));
        else echo hc_category_rail($_categories);
      ?>

      <?php /* PHASE_HC_SECTIONS — the related-articles rule (count, sort,
               featured-only, or a tag filter) lives in the shared builder now, so
               the rail and the reading column cannot drift apart. */ ?>
      <?= hc_related_markup('side') ?>

      <?= hc_render_help_card() ?>
      <?= hc_render_quick_card() ?>
      <?= hc_render_channels_card() ?>
      </aside>
    <?php endif; ?>
  </div>

<?php elseif ($_articleSlug !== ""): ?>
<!-- PHASE_HC_PREMIUM_STATES_2026-08-11 — a real article 404, not a home-page fall-through. -->
<section class="hc-empty hc-state hc-state-not-found" aria-labelledby="hc-article-not-found-title">
  <div class="hc-state-visual" aria-hidden="true"><span></span><strong>404</strong></div>
  <div class="hc-state-copy">
    <span class="hc-state-kicker"><?= hc_esc($__t("article", "Article")) ?></span>
    <h2 id="hc-article-not-found-title"><?= hc_esc($__t("article_not_found", "Article not found")) ?></h2>
    <p><?= hc_esc($__t("not_found_body", "The page may have moved, or the link is no longer available.")) ?></p>
    <div class="hc-state-actions">
      <a class="hc-state-action is-primary" href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t("back_to_hc", "Back to Help Center")) ?></a>
      <a class="hc-state-action" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a>
    </div>
  </div>
</section>

<?php elseif ($_catSlug !== '' && $_category): ?>
<!-- Category article list -->
  <?php /* The old in-content "← Help Center" link is gone: the breadcrumb emitted
          before the shell now carries the way back up, in every category mode. */ ?>




  <?php /* PHASE1_2026-08-06 — in search-only / no-header modes this heading IS the
          page's title, so it centres over the content column like the reference help
          centers do. When a hero or subhero is showing, that band owns the title and
          this block does not render at all. */ ?>
  <?php if (!$showFullHeroOnCategory && !$showCategorySubhero): ?>
    <div class="hc-cat-pg-head hc-cat-pg-head-centered">
      <?php /* PHASE1_2026-08-06 — icon and title share ONE row. They used to be flex
              siblings with the description, so the description's width pushed the icon
              away from the title it belongs to. Owner: "the title icon for category
              stays with the title not far apart". */ ?>
      <div class="hc-cat-pg-titlerow">
        <?php if (!empty($_category["icon"])): $__pg = hc_cat_icon_html((string)$_category["icon"]); $__pgm = (strpos($__pg,'<img')===0||strpos($__pg,'<svg')===0); ?>
          <div class="hc-cat-pg-ico<?= $__pgm ? ' hc-ico-has-img' : '' ?>" aria-hidden="true"><?= $__pg ?></div>
        <?php endif; ?>
        <div class="hc-cat-pg-title"><?= hc_esc((string)$_category["name"]) ?></div>
      </div>
      <div>
        <?php /* PHASE1_2026-08-06 — the category's own description. It was never rendered
                here, so with no hero band the page showed a bare title. Owner: "center the
                category heading include its description". Falls back to the article count
                when the category has no description and stats are on. */ ?>
        <?php $__catDesc = trim((string)($_category["description"] ?? "")); ?>
        <?php if ($__catDesc !== ""): ?>
          <div class="hc-cat-pg-desc"><?= hc_esc($__catDesc) ?></div>
        <?php endif; ?>
        <?php if ($showStats): ?>
          <div class="hc-cat-pg-sub"><?= count($_articles) ?> <?= hc_esc($__t(count($_articles) === 1 ? "article_in" : "articles_in", count($_articles) === 1 ? "article in this category" : "articles in this category")) ?></div>
        <?php endif; ?>
      </div>
    </div>
  <?php endif; ?>
  <?php
  /* PHASE_KB_IMPORT (K3) — surface child categories on a category page, so a
   * parent that groups sub-sections (common in imported help-desk trees)
   * reads as a hub, not an empty list. Pure additive: only renders when the
   * current category actually has children. */
  $__subcats = [];
  $__pid = (int)($_category['id'] ?? 0);
  if ($__pid > 0 && !empty($_categories) && is_array($_categories)) {
      /* PHASE_HC_HIERARCHY_2026-08-15 — `flat` means EVERY subcategory, not just the direct
       * children. This row set was always direct-children-only, which is why "Flat (every
       * subcategory)" and "Top level only" rendered the same page: the label promised the
       * whole subtree and the builder never walked it. This workspace has 45 categories at
       * depth 1, 13 at depth 2 and 1 at depth 3, so the difference is real content.
       * `top` and `nested` keep the direct children — they differ from each other in what
       * they show INSIDE a card, which hc_render_directory() decides. */
      $__sHier = strtolower(trim((string)($_settings['subcat_hierarchy'] ?? 'flat')));
      $__kidsBy = [];
      foreach ($_categories as $__c) $__kidsBy[(int)($__c['parent_id'] ?? 0)][] = $__c;
      if ($__sHier === 'flat') {
          /* Depth-first over the subtree. The seen-set is not decoration: a category whose
           * parent chain loops would otherwise hang the page. */
          $__walk = static function (int $pid, array &$out, array &$seen) use (&$__walk, $__kidsBy): void {
              foreach ($__kidsBy[$pid] ?? [] as $__k) {
                  $__kid = (int) ($__k['id'] ?? 0);
                  if ($__kid <= 0 || isset($seen[$__kid])) continue;
                  $seen[$__kid] = true;
                  $out[] = $__k;
                  $__walk($__kid, $out, $seen);
              }
          };
          $__seen = [];
          $__walk($__pid, $__subcats, $__seen);
      } else {
          foreach ($__kidsBy[$__pid] ?? [] as $__c) $__subcats[] = $__c;
      }
      usort($__subcats, function ($a, $b) {
          $s = ((int)($a['sort_order'] ?? 0)) <=> ((int)($b['sort_order'] ?? 0));
          return $s !== 0 ? $s : strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? ''));
      });
  }
  /* PHASE_HC_SECTIONS — the subcategories section has its own rule. ?subs=all
   * lifts the cap (keeping the sort and any featured/tag filter), so a capped
   * list is never a dead end. */
  $__scr = hc_rule_apply($__subcats, 'subcategories', 'categories');
  if (isset($_GET['subs']) && $_GET['subs'] === 'all' && class_exists('\\OpsIQ\\Kb\\HcSections')) {
      $__scr = \OpsIQ\Kb\HcSections::apply($__subcats, array_merge(hc_rule('subcategories'), ['limit' => 0]), 'categories');
  }
  /* PHASE_HC_SUBCAT_MORE_2026-08-24 — the subcategory "Show all" gained the three
   * controls the categories have had since PHASE9e: what it does, how it looks and
   * its corners. It was a hard-coded link before, so a Help Center could paginate
   * its categories and not its subcategories.
   *
   * EXPAND AND PAGINATE NEED EVERY ROW IN THE DOM. The rule above returns the list
   * already trimmed, which is right for `page` (the extras live behind ?subs=all)
   * and useless for the other two — you cannot reveal or page through markup that
   * was never rendered. So for those the FULL set is rendered and the cap becomes a
   * hide-point, exactly as the home categories do it. */
  /* READ THROUGH $GLOBALS, NOT $_settings. This runs inside hc_build_view(), which
   * does not import $_settings — the fifth time that has caught someone. A bare
   * $_settings here is simply null, so all three silently read as their defaults
   * and the whole feature renders as though it were never configured. */
  $__subCfg    = (array)($GLOBALS['_settings'] ?? []);
  $__subAction = strtolower(trim((string)($__subCfg['subcats_more_action'] ?? 'page')));
  if (!in_array($__subAction, ['page','expand','paginate'], true)) $__subAction = 'page';
  $__subStyle  = strtolower(trim((string)($__subCfg['subcats_more_style'] ?? 'pill')));
  if (!in_array($__subStyle, ['pill','arrow','bar','ghost','minimal'], true)) $__subStyle = 'pill';
  $__subRadius = strtolower(trim((string)($__subCfg['subcats_more_radius'] ?? 'pill')));
  if (!in_array($__subRadius, ['pill','round','soft','sharp'], true)) $__subRadius = 'pill';
  $__subRows   = $__scr['items'];
  $__subLimit  = 0;
  $__subCapped = false;
  /* The section rule's own "more" tick is the master off switch and is checked again
   * at the control below. It has to be checked HERE too: without it, expand and
   * paginate would render the extra rows with a hide-point while the control that
   * reveals them was suppressed — rows in the markup that nothing on the page can
   * ever show. `page` is unaffected either way, since it renders only the capped set. */
  $__subMoreOn = !empty(hc_rule('subcategories')['more']);
  if ($__subAction !== 'page' && $__subMoreOn && $__scr['hidden'] > 0 && class_exists('\\OpsIQ\\Kb\\HcSections')) {
      $__subAll = \OpsIQ\Kb\HcSections::apply($__subcats, array_merge(hc_rule('subcategories'), ['limit' => 0]), 'categories');
      if (!empty($__subAll['items'])) {
          $__subLimit  = count($__scr['items']);   // what stays visible until asked
          $__subRows   = $__subAll['items'];
          $__subCapped = true;
      }
  }
  if (hc_shows('subcategories') && $__scr['items']): ?>
  <div class="hc-subcats">
    <div class="hc-subcats-label"><?= hc_esc($txtBrowseLabel ?: $__t("browse_all", "Browse")) ?> <?= $__scr["total"] ?> <?= hc_esc($__t($__scr["total"] === 1 ? "subcategory_one" : "subcategory_many", $__scr["total"] === 1 ? "subcategory" : "subcategories")) ?></div>
    <?php /* PHASE10K30_2026-08-13 — SUBCATEGORIES GET THE WHOLE ENGINE.
           * They used to call hc_cat_tiles() directly, which meant a tile style
           * and an icon toggle were the only two things an operator could change
           * about them: not one of the twenty-two presentations, none of the
           * directory controls, none of the per-presentation options. They now go
           * through the same dispatcher the home page uses, reading subcat_* —
           * so 'tiles' is still the default and still renders exactly what it did,
           * and everything else is now reachable.
           *
           * Their own children come along: a subcategory with children of its own
           * can list them in a card, the same way a top-level one can. */ ?>
    <?php
      $__subKids = [];
      foreach ($__scr['items'] as $__sc) {
          $__scid = (int)($__sc['id'] ?? 0);
          if ($__scid <= 0) continue;
          foreach (($_categories ?? []) as $__gc) {
              if ((int)($__gc['parent_id'] ?? 0) === $__scid) $__subKids[$__scid][] = $__gc;
          }
      }

      /* PHASE_HC_SUBCAT_PARITY_2026-08-14 — THE ARTICLE POOL HAS TO EXIST ON THIS PAGE.
       *
       * Owner: *"it refuses to show the articles in that sub categories even though i set
       * it to 6."* It was not refusing — it had nothing to show. The directory renderer
       * lists each card's articles out of $_allArticles, and that global is published by
       * hc_render_home(); on a CATEGORY page nothing ever set it, so every directory-style
       * subcategory card rendered "See all N articles" above an empty list, for any
       * preview count, forever.
       *
       * Fetched per subcategory rather than as one wide pool: a category page needs only
       * the cards on it, and 30 apiece covers the largest per-card override (the preview
       * count itself caps at 8). */
      if (!is_array($GLOBALS['_allArticles'] ?? null) || !$GLOBALS['_allArticles']) {
          /* PHASE_HC_DIRLIST_2026-08-15 — the SUBCATEGORY copy of $__dirLayouts, stale in
           * the same two directions and for the same reason. Kept identical to it: a
           * subcategory card lists its own articles exactly as a top-level one does, so
           * two different answers here would mean the same presentation fetched a wide
           * pool on the home page and a narrow one on a category page. */
          $__subDirFamily = ['directory','accordion','compact','split','ribbon','panel','journey','editorial',
                             'bento','console','rail','masonry','index','toc','cloud','marquee','onboard',
                             'archdir','blueprint','kgrid','campus','matrix','spine','cmddir'];
          $__subListsArts = false;
          foreach (['subcat_layout', 'subcat_layout_2', 'subcat_layout_3'] as $__slk) {
              if (in_array(strtolower((string)($GLOBALS['_settings'][$__slk] ?? '')), $__subDirFamily, true)) $__subListsArts = true;
          }
          if ($__subListsArts) {
              /* PHASE_HC_SUBCAT_MORE_2026-08-24 — FETCH FOR THE ROWS THAT RENDER, NOT
               * THE CAPPED ONES. In expand and paginate the section renders the FULL
               * set behind a hide-point, so the cards beyond the cap were being drawn
               * from a pool that only ever held the first page's subcategories: page
               * two showed "See all 3 articles" above nothing at all. $__subRows IS
               * $__scr['items'] in `page` mode, so this is the same fetch it always
               * was there. */
              $__pool = [];
              foreach ($__subRows as $__sc) {
                  $__scid = (int)($__sc['id'] ?? 0);
                  if ($__scid <= 0) continue;
                  foreach (\OpsIQ\Kb\HelpCenter::listPublicArticles((string)$_siteKey, $__scid, 30, 0) as $__pa) $__pool[] = $__pa;
              }
              /* Same defect on the category surface — overlay before publishing. */
              try { \OpsIQ\Kb\HcTranslator::overlayArticles($__pool, $_siteKey, $_locale); } catch (\Throwable $e) {}
              $GLOBALS['_allArticles'] = $__pool;
          }
      }
    ?>
    <?= hc_render_cat_presentations($__subRows, 'subcat',
          (string)($subcategoryStyle ?? 'card'), !empty($subcategoryIcons),
          $__subKids, $__subLimit, $__subCapped, 'hc-subcats-grid') ?>
    <?php if ($__scr['hidden'] > 0 && $__subMoreOn):
      /* The href is the same link it always was, so `page` is unchanged and the
         other two still work with JavaScript off. data-hc-more / data-hc-paginate
         are what let the client take over — the same attributes, and therefore the
         same engine, the categories carousel uses. */
      $__subHref = hc_u('cat=' . urlencode((string)($_category['slug'] ?? '')) . '&subs=all'); ?>
      <div class="hc-cats-more-wrap hc-cats-more-v-<?= hc_esc($__subStyle) ?> hc-cats-more-r-<?= hc_esc($__subRadius) ?>">
        <a class="hc-cats-more" href="<?= hc_esc($__subHref) ?>"<?= $__subAction === 'expand' ? ' data-hc-more' : ($__subAction === 'paginate' ? ' data-hc-paginate' : '') ?>>
          <span class="hc-cats-more-label"><?= $__subAction === 'paginate'
                ? hc_esc($__t('see_more_cats', 'See more'))
                : hc_esc(sprintf($__t('show_all_n_subcategories', 'Show all %d subcategories'), (int)$__scr['total'])) ?></span>
          <?php if ($__subAction !== 'paginate'): ?><span class="hca-count hc-cats-more-rest" aria-hidden="true">+<?= (int)$__scr['hidden'] ?></span><?php endif; ?>
          <svg class="hc-cats-more-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
        </a>
        <?php if ($__subAction === 'paginate'): ?>
        <div class="hc-cats-dots" role="tablist" aria-label="<?= hc_esc($__t('cats_pages', 'Category pages')) ?>" data-page-label="<?= hc_esc($__t('cats_page_n', 'Page {n}')) ?>"></div>
        <?php endif; ?>
      </div>
    <?php endif; ?>
  </div>
  <?php endif; ?>
  <?php
  /* PHASE_HC_SECTIONS — the category's article list has its own rule (all / a set
   * number / latest / featured / custom). ?arts=all lifts the cap. */
  $__ar = hc_rule_apply($_articles, 'category_articles', 'articles');
  $__artsAll = (isset($_GET['arts']) && $_GET['arts'] === 'all');
  if ($__artsAll && class_exists('\\OpsIQ\\Kb\\HcSections')) {
      $__ar = \OpsIQ\Kb\HcSections::apply($_articles, array_merge(hc_rule('category_articles'), ['limit' => 0]), 'articles');
  }
  /* PHASE10K8_2026-08-11 — WHICH CTA SURFACE this page is. ?arts=all is the
   * "view all articles" page and carries its own enable/placement/wording; the
   * capped category page is the "categories" surface. One template, two
   * surfaces, because they are two different reading situations. */
  $__ctaSurfHere = $__artsAll ? 'all' : 'categories';
  ?>
  <?php hc_cta_at($__ctaSurfHere, 'above_listing'); hc_cta_at($__ctaSurfHere, 'before_listing'); hc_cta_at($__ctaSurfHere, 'after_heading'); ?>
  <?php if (hc_shows('category_articles') && $__ar['items']): ?>
  <?php if ($__subcats): ?><div class="hc-subcats-label hc-subcats-arts-label"><?= $__ar["total"] ?> <?= hc_esc($__t($__ar["total"] === 1 ? "article_one" : "article_many", $__ar["total"] === 1 ? "article" : "articles")) ?> · <?= hc_esc($__t("in_this_section", "In this section")) ?></div><?php endif; ?>
  <div class="hc-alist hc-cards-<?= hc_esc($articleCardStyle ?: 'list') ?><?= count($__ar['items']) === 1 ? ' hc-alist-solo' : '' ?>" role="list">
    <?php /* PHASE10J — one row builder, twelve presentations; the search list
             below uses the SAME builder, so the two can no longer drift.
             PHASE10K8 — the in-listing placements ("after N articles", "between
             groups") are emitted BETWEEN rows, inside the same list. */ ?>
    <?php $__arN = count($__ar['items']);
          foreach (array_values($__ar['items']) as $__ai => $art) {
              echo hc_arow_item($art, (int)$__ai);
              echo hc_cta_between($__ctaSurfHere, (int)$__ai, $__arN,
                                  $__ctaSurfHere === 'all' ? 'between_groups' : 'between_sections');
          } ?>
  </div>
  <?php if ($__ar['hidden'] > 0 && !empty(hc_rule('category_articles')['more'])): ?>
    <div class="hc-cats-more-wrap">
      <a class="hc-cats-more" href="<?= hc_esc(hc_u('cat=' . urlencode((string)($_category['slug'] ?? '')) . '&arts=all')) ?>">
        <?= hc_esc($__t('show_all_arts', 'Show all articles')) ?> (<?= (int)$__ar['total'] ?>) <span class="hc-cats-more-rest" aria-hidden="true">+<?= $__ar['hidden'] ?></span>
      </a>
    </div>
  <?php endif; ?>
  <?php /* PHASE10K8_2026-08-11 — "below the listing" on the categories surface.
           Was a hardcoded block assembled from flat keys; now one placement of
           the one component. The "view all articles" surface has its own
           settings and its own slots further down. */ ?>
  <?php hc_cta_at($__ctaSurfHere, 'below_listing'); hc_cta_at($__ctaSurfHere, 'after_listing'); ?>
  <?php elseif (!$__subcats): ?>
  <section class="hc-empty hc-state hc-state-empty" aria-labelledby="hc-empty-category-title">
    <div class="hc-state-visual" aria-hidden="true"><span></span><strong>00</strong></div>
    <div class="hc-state-copy">
      <span class="hc-state-kicker"><?= hc_esc($__t("category", "Category")) ?></span>
      <h2 id="hc-empty-category-title"><?= hc_esc($__t("empty_category_title", "No articles yet")) ?></h2>
      <p><?= hc_esc($__t("category_filling", "This category is being filled in.")) ?></p>
      <div class="hc-state-actions">
        <a class="hc-state-action is-primary" href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t("browse_all_cats", "Browse all categories")) ?></a>
        <a class="hc-state-action" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a>
      </div>
    </div>
  </section>
  <?php endif; ?>

<?php elseif ($_catSlug !== ''): ?>
<!-- PHASE_HC_PREMIUM_STATES_2026-08-11 — category 404 with clear recovery paths. -->
<section class="hc-empty hc-state hc-state-not-found" aria-labelledby="hc-category-not-found-title">
  <div class="hc-state-visual" aria-hidden="true"><span></span><strong>404</strong></div>
  <div class="hc-state-copy">
    <span class="hc-state-kicker"><?= hc_esc($__t("category", "Category")) ?></span>
    <h2 id="hc-category-not-found-title"><?= hc_esc($__t("category_not_found", "Category not found")) ?></h2>
    <p><?= hc_esc($__t("not_found_body", "The page may have moved, or the link is no longer available.")) ?></p>
    <div class="hc-state-actions">
      <a class="hc-state-action is-primary" href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t("back_to_hc", "Back to Help Center")) ?></a>
      <a class="hc-state-action" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a>
    </div>
  </div>
</section>

<?php elseif ($_query !== ''): ?>
<!-- Search results — hero is already rendered above (isHome=true when query set) -->
  <section class="hc-srch-results-hd<?= !$_articles ? " hc-empty hc-state hc-state-search" : "" ?>" aria-labelledby="hc-search-results-title">
    <?php if (!$_articles): ?>
    <div class="hc-state-visual" aria-hidden="true"><span></span><strong>00</strong></div>
    <span class="hc-state-kicker"><?= hc_esc($__t("search_results", "Search results")) ?></span>
    <?php endif; ?>
    <h2 class="hc-srch-results-title" id="hc-search-results-title">
      <?php if ($_articles): ?>
<?php /* PHASE_HC_I18N — {n} = count, {q} = the highlighted query. */
              echo str_replace(['{n}', '{q}'],
                [(int)($_searchTotal ?? count($_articles)), '<em>"' . hc_esc($_query) . '"</em>'],
                hc_esc($__t('search_results_count', '{n} results for {q}'))); ?>
      <?php else: ?>
        <span class="hc-no-results-msg"><?= hc_esc($txtNoResults) ?></span>
      <?php endif; ?>
    </h2>
    <?php if (!$_articles): ?><p class="hc-srch-results-hint hc-state-actions"><a class="hc-state-action is-primary" href="<?= hc_esc(hc_u()) ?>"><?= hc_esc($__t("browse_all_cats", "Browse all categories")) ?></a><a class="hc-state-action" href="<?= hc_esc(hc_u() . "#hc-hero-input") ?>"><?= hc_esc($__t("search_btn", "Search")) ?></a></p><?php endif; ?>
  </section>
  <?php /* PHASE10K8 — the search surface's own placements. */ ?>
  <?php hc_cta_at('search', 'below_heading'); hc_cta_at('search', 'above_results'); ?>
  <?php if ($_articles): ?>
  <div class="hc-alist hc-cards-<?= hc_esc($articleCardStyle ?: 'list') ?><?= count($_articles) === 1 ? ' hc-alist-solo' : '' ?>" role="list">
    <?php $__srN = count($_articles);
          foreach (array_values($_articles) as $__ai => $art) {
              echo hc_arow_item($art, (int)$__ai);
              echo hc_cta_between('search', (int)$__ai, $__srN, 'between_results');
          } ?>
  </div>
  <?php hc_cta_at('search', 'bottom_results'); ?>
  <?php if (($_searchPages ?? 1) > 1):
      $__pgCur = (int)($_searchPage ?? 1); $__pgN = (int)$_searchPages;
      $__pgUrl = static fn(int $n): string => hc_u('q=' . urlencode($_query) . ($n > 1 ? '&page=' . $n : ''));
      /* window: first, last, current ±2, with ellipses */
      $__pgShow = []; foreach ([1, 2, $__pgCur - 2, $__pgCur - 1, $__pgCur, $__pgCur + 1, $__pgCur + 2, $__pgN - 1, $__pgN] as $__n) if ($__n >= 1 && $__n <= $__pgN) $__pgShow[$__n] = true;
      ksort($__pgShow); ?>
  <nav class="hc-pager" aria-label="<?= hc_esc($__t('search_results', 'Search results')) ?>">
    <?php if ($__pgCur > 1): ?><a class="hc-pager-btn hc-pager-prev" rel="prev" href="<?= hc_esc($__pgUrl($__pgCur - 1)) ?>">‹ <?= hc_esc($__t('nav_prev', 'Previous')) ?></a><?php endif; ?>
    <ol class="hc-pager-list"><?php $__last = 0; foreach (array_keys($__pgShow) as $__n): ?>
      <?php if ($__n - $__last > 1): ?><li class="hc-pager-gap" aria-hidden="true">…</li><?php endif; ?>
      <li><?php if ($__n === $__pgCur): ?><span class="hc-pager-num is-current" aria-current="page"><?= $__n ?></span><?php else: ?><a class="hc-pager-num" href="<?= hc_esc($__pgUrl($__n)) ?>"><?= $__n ?></a><?php endif; ?></li>
    <?php $__last = $__n; endforeach; ?></ol>
    <?php if ($__pgCur < $__pgN): ?><a class="hc-pager-btn hc-pager-next" rel="next" href="<?= hc_esc($__pgUrl($__pgCur + 1)) ?>"><?= hc_esc($__t('nav_next', 'Next')) ?> ›</a><?php endif; ?>
  </nav>
  <?php endif; ?>
  <?php endif; ?>

<?php else: ?>
<!-- Home — category index -->
  <?php
  /* PHASE_HC_SECTIONS — pull the FULL lists, then let each section's rule decide
   * what is shown. Fetching only `popular_articles_limit` rows up front made the
   * rule meaningless: "show the 5 newest" cannot work on a list that was already
   * cut to 8 by popularity. Fetch wide, filter by rule, cap last. */
  $_catsWithArticles = array_values(array_filter($_categories, function($c){ return (int)($c['article_count'] ?? 0) > 0; }));
  /* PUBLISHED TO $GLOBALS, not just assigned.
   *
   * hc_build_view() does NOT import $_allArticles, so a plain assignment here creates a
   * LOCAL. hc_render_home() declares `global $_allArticles` and therefore read a
   * variable nothing had ever set: an empty array. The visible result was 64 directory
   * cards that each said "See all 39 articles" with NOT ONE article listed above it —
   * the entire point of the directory presentation, silently absent.
   *
   * Same family as the $_settings trap in this file, inverted: there the READER was
   * unimported, here the WRITER is. Publishing explicitly is the fix in both directions,
   * because appending to this function's shared global list has broken other blocks. */
  /* THE FETCH WIDTH IS DECIDED BY WHAT WILL RENDER.
   *
   * 200 was fine while the home page only needed a popular/latest row. The directory
   * presentations list each category's OWN articles from this same array, and on this
   * workspace 200 rows covered 64 of 102 categories — so 38 categories rendered a card
   * saying "See all N articles" above nothing at all, permanently.
   *
   * Widen only when a directory-style layout is actually selected. Tiles never needed
   * the rows and should not pay for them. */
  /* PHASE_HC_DIRLIST_2026-08-15 — THE LAYOUTS THAT LIST A CATEGORY'S OWN ARTICLES.
   *
   * This list had gone stale in BOTH directions and the comment above describes
   * exactly the bug that came back:
   *
   *   MISSING (7)  archdir, blueprint, kgrid, campus, matrix, spine, cmddir — the
   *                architectures added after this list was written. Choosing one
   *                kept the NARROW pool, so a card said "See all 39 articles" above
   *                nothing at all. Seven of the twenty-two presentations the Studio
   *                offers were back in the state PHASE_HC_SECTIONS fixed.
   *   PRESENT IN ERROR (1)  `tree` sets $showArts = false (help.php:4372) — it is the
   *                numeral card and deliberately lists no articles. It was paying for
   *                a 2,000-row fetch on every home render and using none of it.
   *
   * KEPT ON PURPOSE: the five retired names. This test runs on the STORED value,
   * before hc_render_directory() remaps them, and all five remap to architectures
   * that DO list articles — so dropping them here would re-break the very workspaces
   * the remap exists to protect.
   *
   * DELIBERATELY OUT: `krail`, which sets $showArts = false for the same reason `tree`
   * does. It is an index of links, not a directory of previews.
   *
   * HcDirectoryArticlePoolTest derives this set from the renderer's own allowlist,
   * its $showArts suppressions and the $__retired map, and fails if they drift. */
  $__dirLayouts = ['directory', 'accordion', 'compact', 'split', 'ribbon', 'panel', 'journey', 'editorial',
                   'bento', 'console', 'rail', 'masonry', 'index', 'toc', 'cloud', 'marquee', 'onboard',
                   'archdir', 'blueprint', 'kgrid', 'campus', 'matrix', 'spine', 'cmddir'];
  /* PHASE_HC_SUBCAT_PARITY_2026-08-14 — EVERY presentation slot, BOTH prefixes.
   * This read `cat_layout` alone, so a home leading with tiles and continuing in a
   * directory kept the narrow pool, and the second block's cards listed nothing. */
  $__needWide = false;
  foreach (['cat_layout', 'cat_layout_2', 'cat_layout_3',
            'subcat_layout', 'subcat_layout_2', 'subcat_layout_3'] as $__lk) {
      if (in_array(strtolower((string)($GLOBALS['_settings'][$__lk] ?? '')), $__dirLayouts, true)) $__needWide = true;
  }
  $_allArticles = HelpCenter::listPublicArticles($_siteKey, null, $__needWide ? 2000 : 200, 0);
  /* PHASE_HC_LISTING_I18N_2026-08-15 — OVERLAY BEFORE PUBLISHING, NOT AFTER.
   * The directory presentations read their article pool straight off this global
   * (help.php:4861), while $_homeArts is derived from it by array_values() — a COPY — and
   * only that copy was ever overlaid. So a workspace whose articles ARE translated, and
   * paid for, still showed English titles in every directory card. Overlaying here covers
   * both, because the copy is taken afterwards. */
  try { \OpsIQ\Kb\HcTranslator::overlayArticles($_allArticles, $_siteKey, $_locale); } catch (\Throwable $e) {}
  $GLOBALS['_allArticles'] = $_allArticles;
  $_homeArts = hc_rule_apply(array_values($_allArticles), 'home_articles', 'articles');
  /* PHASE_HC_HOME_FEATURED — the articles for the home row. The SOURCE is a setting:
   * featured (★ articles, queried direct so a low-view one still shows), popular
   * (most viewed) or latest (newest). Only computed when the block is enabled. */
  $_homeFeatured = [];
  if (!empty($homeFeaturedEnabled)) {
      $__hfLim = (int)($homeFeaturedLimit ?? 6);
      if (($homeFeaturedSource ?? 'featured') === 'popular') {
          $_homeFeatured = HelpCenter::listPublicArticles($_siteKey, null, $__hfLim, 0);   // ordered by views
      } elseif (($homeFeaturedSource ?? 'featured') === 'latest') {
          $_homeFeatured = HelpCenter::listLatestArticles($_siteKey, $__hfLim);
      } else {
          $_homeFeatured = HelpCenter::listFeaturedArticles($_siteKey, $__hfLim);
      }
  }
  /* NEWS AND UPDATES — fetched here, beside the featured row, so both go through
   * the same translation overlay below and the renderer stays a renderer. */
  $_homeNews = [];
  if (!empty($homeNewsEnabled)) {
      $_homeNews = hc_news_feed($_siteKey, (string)($homeNewsFeed ?? 'both'), (int)($homeNewsLimit ?? 6));
        /* PHASE5_2026-08-07 — the operator's manual picks lead the feed.
         *
         * Chosen articles come first because they are a deliberate editorial choice,
         * while the category feed is ordered by views. They are de-duplicated against
         * the category results by slug — an article that is both chosen AND filed in
         * News would otherwise appear twice in a six-item strip.
         *
         * A slug that no longer resolves is skipped rather than rendering an empty
         * card: articles get unpublished and renamed, and a dead pick should quietly
         * fall out of the strip rather than leave a hole in it. */
        /* $_settings is NOT in hc_build_view()'s global import list, so reading it
         * directly here yields null and the picks silently vanish — verified with an
         * inline probe, which showed the block running with an empty value while the
         * setting was stored correctly. Third time this programme a missing global
         * has produced a control that saves and does nothing, so this reads from
         * $GLOBALS explicitly rather than depending on an import that is easy to
         * forget when the list is 40 names long. */
        $__hcSettings = $GLOBALS['_settings'] ?? [];
        $__newsPicks = trim((string)($__hcSettings['home_news_items'] ?? ''));
        if ($__newsPicks !== '') {
            $__picked = [];
            $__seenSlug = [];
            foreach (array_slice(array_filter(array_map('trim', explode(',', $__newsPicks))), 0, 24) as $__slug) {
                if (isset($__seenSlug[$__slug])) continue;
                $__seenSlug[$__slug] = true;
                try { $__a = \OpsIQ\Kb\HelpCenter::getArticleBySlug($__slug, $_siteKey); }
                catch (\Throwable $e) { continue; }
                if (is_array($__a) && trim((string)($__a['page_title'] ?? '')) !== '') $__picked[] = $__a;
            }
            if ($__picked) {
                foreach ($_homeNews as $__n) {
                    $__s = trim((string)($__n['slug'] ?? ''));
                    if ($__s !== '' && isset($__seenSlug[$__s])) continue;
                    $__picked[] = $__n;
                }
                $_homeNews = array_slice($__picked, 0, max(1, (int)($homeNewsLimit ?? 6)));
                /* $_homeNews is local here for the same reason $_settings was: this
                 * function does not import it. The renderer reads the GLOBAL later, so
                 * writing only the local would discard the merge silently — which is
                 * exactly what it did until this line existed. */
                $GLOBALS['_homeNews'] = $_homeNews;
            }
        }
  }
  /* QUICK LINKS, `tabs` design — the enterprise block's own contract, ported.
   *
   * A tab is a grouping the OPERATOR invents, not a category. `auto` is the
   * default source and fills the tab from its own NAME, which is what makes the
   * section shippable to any workspace: no two customers share category ids, so
   * a default built from one company's structure works for exactly one company.
   * Name a tab and it is already correct. A category, tag or explicit list is an
   * explicit narrowing on top of that.
   *
   * Every tab is resolved BEFORE anything is emitted, so a tab whose source
   * yields nothing is dropped with its panel rather than leaving a dead rail
   * entry pointing at a panel that was never rendered. */
  $_homeQuickTabs = [];
  if (!empty($homeQuickEnabled) && ($homeQuickStyle ?? 'tiles') === 'tabs' && $homeQuickItems) {
   try {
      $__allCats = [];
      try { $__allCats = (array)HelpCenter::listCategories($_siteKey); } catch (\Throwable $e) { $__allCats = []; }
      $__capAll = max(1, min(30, (int)($homeQuickLimit ?? 8)));
      foreach ($homeQuickItems as $__qi => $__q) {
          $__lbl = trim((string)($__q['label'] ?? ''));
          if ($__lbl === '') continue;
          $__src = strtolower(trim((string)($__q['source'] ?? 'auto')));
          $__cap = max(1, min(30, (int)($__q['limit'] ?? 0) ?: $__capAll));
          $__rows = [];
          try {
              if ($__src === 'category') {
                  $__cid = 0;
                  $__want = trim((string)($__q['category'] ?? ''));
                  if ($__want === '' && preg_match('~[?&]cat=([^&#]+)~', (string)($__q['url'] ?? ''), $__m)) {
                      $__want = urldecode($__m[1]);
                  }
                  foreach ($__allCats as $__c) {
                      if (!is_array($__c)) continue;
                      if ((string)($__c['slug'] ?? '') === $__want || (string)(int)($__c['id'] ?? 0) === $__want) {
                          $__cid = (int)($__c['id'] ?? 0); break;
                      }
                  }
                  if ($__cid > 0) $__rows = (array)HelpCenter::listPublicArticles($_siteKey, $__cid, $__cap);
              } elseif ($__src === 'latest') {
                  $__rows = (array)HelpCenter::listLatestArticles($_siteKey, $__cap);
              } elseif ($__src === 'popular') {
                  $__rows = (array)HelpCenter::listPublicArticles($_siteKey, null, $__cap, 0);
              } else {
                  /* AUTO — the tab's own name is the query, and it falls through
                   * three steps rather than giving up on the first miss:
                   *
                   *   1. the topic resolver (words → categories → articles);
                   *   2. plain search, which catches names the resolver's word
                   *      rules do not;
                   *   3. the most-read articles.
                   *
                   * Step 3 is what makes an EDITORIAL name work. "Common topics"
                   * is not a word anything in the library contains — it is a
                   * promise about what is inside — so matching it literally
                   * returns nothing and the panel came back empty. What the
                   * operator means by it is "what people ask most", and that is
                   * exactly the most-read list. The same rescue covers any name
                   * that happens not to match, so a tab is never a dead panel. */
                  $__rows = class_exists('\\OpsIQ\\Kb\\HcAutoTopic')
                      ? (array)\OpsIQ\Kb\HcAutoTopic::resolve($__lbl, $_siteKey, $__cap, $__allCats)
                      : [];
                  if (!$__rows) $__rows = (array)HelpCenter::search($__lbl, $_siteKey, $__cap);
                  if (!$__rows) $__rows = (array)HelpCenter::listPublicArticles($_siteKey, null, $__cap, 0);
              }
          } catch (\Throwable $e) { $__rows = []; }

          /* Deduped by title: importers leave near-twins under one heading, and
           * a panel listing the same sentence twice reads as a rendering fault. */
          $__seenT = []; $__clean = [];
          foreach ($__rows as $__r) {
              if (!is_array($__r)) continue;
              $__ttl = mb_strtolower(trim((string)($__r['page_title'] ?? '')));
              $__slug = (string)($__r['slug'] ?? '');
              if ($__ttl === '' || $__slug === '' || isset($__seenT[$__ttl])) continue;
              $__seenT[$__ttl] = true;
              $__clean[] = $__r;
              if (count($__clean) >= $__cap) break;
          }
          /* A TAB NEVER DISAPPEARS. Dropping the ones that resolved to nothing
           * meant renaming a tab made it vanish mid-edit — the operator types a
           * new name, the old articles stop matching, and the tab they are
           * working on is gone from the page. It stays, with an honest empty
           * line, so a rename is visibly a rename and not a deletion. */
          if (!$__clean) { $_homeQuickTabs[$__qi] = []; continue; }
          /* GROUPED shows a sub-heading per category, the way the design does;
           * FLAT is just the articles. Same rows either way — this only decides
           * whether they are headed, so switching is never a re-fetch. */
          if (($homeQuickDisplay ?? 'grouped') === 'flat') {
              $_homeQuickTabs[$__qi] = [['label' => '', 'items' => $__clean]];
          } else {
              $__byCat = [];
              foreach ($__clean as $__r) {
                  $__cn = '';
                  foreach ($__allCats as $__c3) {
                      if (is_array($__c3) && (int)($__c3['id'] ?? 0) === (int)($__r['category_id'] ?? 0)) {
                          $__cn = trim((string)($__c3['name'] ?? '')); break;
                      }
                  }
                  $__byCat[$__cn][] = $__r;
              }
              $__gs = [];
              foreach ($__byCat as $__cn => $__rows2) $__gs[] = ['label' => (string)$__cn, 'items' => $__rows2];
              /* One group needs no heading — a lone sub-heading repeating the
               * tab's own subject is noise. */
              if (count($__gs) === 1) $__gs[0]['label'] = '';
              $_homeQuickTabs[$__qi] = $__gs;
          }
      }
   } catch (\Throwable $e) {
       /* A quick-links panel is a nicety. It must never be able to take the home
        * page down with it, and when it does fail the reason has to be findable. */
       @file_put_contents('/home/opsiqai/hc-quick.log', date('c') . ' ' . get_class($e) . ': ' . $e->getMessage()
           . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n", FILE_APPEND);
       $_homeQuickTabs = [];
   }
  }

  /* PHASE_HC_I18N_AI — the HOME page builds its OWN collections (featured row, the
   * category grid with nested articles, the flat article list). Overlay each before
   * render so home cards match the translated detail pages. */
  if ($_i18nOn && $_locale !== $_i18nSource && class_exists('\\OpsIQ\\Kb\\HcTranslator')) {
      try {
          if (!empty($_homeFeatured) && is_array($_homeFeatured)) \OpsIQ\Kb\HcTranslator::overlayArticles($_homeFeatured, $_siteKey, $_locale);
          if (!empty($_homeNews) && is_array($_homeNews)) \OpsIQ\Kb\HcTranslator::overlayArticles($_homeNews, $_siteKey, $_locale);
          foreach ($_homeQuickTabs as &$__qg) { if (is_array($__qg) && $__qg) \OpsIQ\Kb\HcTranslator::overlayArticles($__qg, $_siteKey, $_locale); } unset($__qg);
          if (!empty($_homeArts['items']) && is_array($_homeArts['items'])) \OpsIQ\Kb\HcTranslator::overlayArticles($_homeArts['items'], $_siteKey, $_locale);
          if (!empty($_catsWithArticles) && is_array($_catsWithArticles)) {
              \OpsIQ\Kb\HcTranslator::overlayCategories($_catsWithArticles, $_siteKey, $_locale);
              foreach ($_catsWithArticles as &$__cwa) {
                  if (!empty($__cwa['articles']) && is_array($__cwa['articles'])) \OpsIQ\Kb\HcTranslator::overlayArticles($__cwa['articles'], $_siteKey, $_locale);
              }
              unset($__cwa);
          }
      } catch (\Throwable $e) { /* never blank the page for a translation */ }
  }
  ?>
  <?php /* The two new sections are rendered HERE, not inside hc_render_home, so
           that function keeps taking the same three arguments it always has. It
           picks them up as globals and places each per its own position setting. */
    $hcHomeNewsHtml  = !empty($homeNewsEnabled)  ? hc_render_news($_homeNews) : '';
    $hcHomeQuickHtml = !empty($homeQuickEnabled) ? hc_render_quick($homeQuickItems, $_homeQuickTabs) : '';
    /* PHASE10K8_2026-08-11 — the home band now goes through the component like
     * every other surface: same renderer, same overrides, same slot guard. The
     * home surface's own placement decides whether it sits above the sections,
     * below them, or at the very bottom of the page. hc_render_home() still
     * reads $homeCtaPosition, so that global follows the component rather than
     * the other way round. */
    $__homePlace = (string)($GLOBALS['__hcCtaSurfaces']['home']['place'] ?? 'below');
    if ($__homePlace === 'above' || $__homePlace === 'below') $homeCtaPosition = $__homePlace;
    $__ctaHtml = hc_cta_slot('home', $__homePlace);
    /* Full bleed goes to the slot outside the content column; contained and wide
     * stay in the flow between the sections, where they belong. A band placed at
     * the bottom of the page uses that same outside slot by definition. */
    $__ctaFull = ($homeCtaWidth ?? 'medium') === 'full' || $__homePlace === 'bottom';
    $hcHomeCtaHtml = $__ctaFull ? '' : $__ctaHtml;
    $GLOBALS['hcHomeCtaFullHtml'] = $__ctaFull ? $__ctaHtml : '';
  ?>
  <?= hc_render_home($_catsWithArticles, $_homeArts['items'], $_homeFeatured) ?>
<?php endif; ?>

    </div>
    <?php /* PHASE2_2026-08-06 — respect the per-surface rail toggles. $__railOff has
            already collapsed the shell to one column; this stops the rail markup being
            emitted into it. */ ?>
    <?php if (!$_article && empty($__railOff)):
      if (($homeLayout ?? 'categories') === 'categories') {
        /* categories mode: no rail on the home index (the tiles ARE the index);
         * on a category page the rail lists that category's articles; on the
         * SEARCH results page show the category browse rail so the side nav is
         * present there too (clicking a tag lands here). */
        if ($_catSlug !== '' && $_category) {
          /* PHASE2_2026-08-06 — the duplication fix.
           *
           * This used to hand the full-width page hc_category_articles_rail(), which
           * lists THE SAME category's articles the main column is already showing —
           * so a category page printed one list twice, side by side. The old comment
           * here even described the problem and then fixed it for the widget panel
           * only, leaving the full-width page as the audit found it.
           *
           * The rail now defaults to the category TREE, which takes the reader
           * somewhere they are not. `sidebar_category_content = 'articles'` restores
           * the old behaviour for anyone who wants it. The widget panel keeps the
           * tree either way: in a 420px panel the repeat was worst of all. */
          echo (!empty($_widget) || $sidebarCatContent !== 'articles')
            ? hc_category_rail($_categories)
            : hc_category_articles_rail((int)($_category['id'] ?? 0), (string)($_category['name'] ?? ''));
        } elseif ($_query !== '') {
          echo hc_category_rail($_categories);
        }
      } else {
        echo hc_category_rail($_categories);
      }
      /* PHASE2_2026-08-06 — the help card sits UNDER the rail, as its own module.
       * Only where a rail actually renders: on the categories-mode home the tiles ARE
       * the index and there is no rail, so a card there would float in empty space. */
      if (empty($__hcNoRail)) { echo hc_render_help_card(); echo hc_render_quick_card(); echo hc_render_channels_card(); }
    endif; ?>
  </div>
</main>
<?php /* THE FULL-BLEED BAND SITS HERE: inside #hc-page, outside #hc-main.
       That is the only place it can be. Inside the content column its paint is
       clipped by the content column's own overflow no matter how far its box
       breaks out — the box goes edge to edge and the visible band does not.
       Outside #hc-page it loses every `#hc-page .hce-*` rule and renders as bare
       text. Between the two is where the hero already lives, which is exactly
       why the hero is genuinely edge to edge on this page. */ ?>
<?= (string)($GLOBALS['hcHomeCtaFullHtml'] ?? '') ?>
<?php /* PHASE10K8_2026-08-11 — "bottom of the page", for whichever surface this
         request is. Same slot the full-width home band uses: inside #hc-page,
         outside the content column, which is the only place a band can be
         genuinely edge to edge AND still flush against the footer. */ ?>
<?php
  $__botSurface = 'home';
  if (!empty($_article))                    $__botSurface = 'article';
  elseif (($_query ?? '') !== '')           $__botSurface = 'search';
  elseif (($_catSlug ?? '') !== '')         $__botSurface = (isset($_GET['arts']) && $_GET['arts'] === 'all') ? 'all' : 'categories';
  if ($__botSurface !== 'home' || ($GLOBALS['hcHomeCtaFullHtml'] ?? '') === '') echo hc_cta_slot($__botSurface, 'bottom');
?>
</div>
<?php
    return (string)ob_get_clean();
}

/* ── AJAX page navigation response ──────────────────────────────────────── */
/* PHASE_HELP_PROXY — same-origin article feedback. Votes used to POST to
 * /opsiq/remote_beacon.php (cross-origin), which a help center served on the
 * customer's own domain via a path proxy cannot reach: the beacon fails CORS
 * closed for non-allowlisted origins, and /opsiq is outside the proxied /help
 * prefix anyway. Routing the vote back through THIS page keeps it same-origin, so
 * it records identically whether served direct or behind a proxy. */
if (!empty($_GET['_hcajax']) && !empty($_GET['_feedback'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('X-Robots-Tag: noindex');
    $__fbIn = json_decode((string)file_get_contents('php://input'), true);
    if (!is_array($__fbIn)) $__fbIn = $_POST;
    $__fbId  = (int)($__fbIn['article_id'] ?? 0);
    $__fbHlp = !empty($__fbIn['helpful']);
    /* PHASE_HC_FEEDBACK_SCOPE_2026-08-17 — the client's `vid` WAS the dedup key.
     * recordFeedback() dedups on (article, voter, surface) and took `voter` straight from
     * this field, so rotating one string walked past the limit and inflated any article's
     * counters in a loop — each pass also writing an hc_events row and a Voice-of-Customer
     * record. A vote is now keyed by the SERVER's view of the voter: the portal branch
     * below still binds a signed-in customer, and everyone else is keyed by IP inside
     * recordFeedback(). The field is no longer read for the public page surface. */
    $__fbVid = '';
    /* REPLY_FEEDBACK P8.4 — surface identity for the vote itself. The PORTAL's
     * /hc path records as the portal surface (server-side path flag, never a
     * client field), and a signed-in portal customer becomes the voter key,
     * so their vote follows them across devices. */
    $__fbSurface = $__isPortalHcPath ? 'portal' : 'page';
    if ($__isPortalHcPath) {
        try {
            if (class_exists('\\OpsIQ\\Auth\\ClientAuth') && \OpsIQ\Auth\ClientAuth::check()) {
                $__fbCid = (int)\OpsIQ\Auth\ClientAuth::id();
                if ($__fbCid > 0) $__fbVid = 'client:' . $__fbCid;
            }
        } catch (\Throwable $e) {}
    }
    $__fbCounted = false;
    if ($__fbId > 0 && method_exists('\\OpsIQ\\Kb\\HelpCenter', 'recordFeedback')) {
        try {
            $__fbCounted = (bool)HelpCenter::recordFeedback($__fbId, $__fbHlp, $__fbVid, $__fbSurface, (string)$_siteKey);
            /* KB-019 — reason/comment ride only on a counted vote. */
            if ($__fbCounted && method_exists('\\OpsIQ\\Kb\\HelpCenter', 'recordFeedbackReason')) {
                HelpCenter::recordFeedbackReason(
                    $__fbId, $__fbHlp,
                    (string)($__fbIn['reason'] ?? ''), (string)($__fbIn['comment'] ?? ''),
                    $__fbVid, $__fbSurface
                );
            }
        } catch (\Throwable $e) {}
    }
    echo json_encode(['success' => true, 'counted' => $__fbCounted]);
    exit;
}
/* PHASE6_2026-08-07 — ARTICLE DISCUSSIONS, the public adapter.
 *
 * WHY THIS LIVES HERE AND NOT ON THE EXISTING ROUTES. `opsiq.kb_discussions.php`
 * already exposes nine routes over the same engine, but they resolve the workspace
 * through `Site::activeWorkspaceKey()`, which is an ADMIN/PORTAL notion. A public
 * help centre reached over its own domain, or reverse-proxied onto a customer
 * domain, has no such active workspace — it has `$_siteKey`, resolved from the host.
 * Calling those routes from here would either scope to nothing or, worse, to
 * whichever workspace the ambient resolver happened to answer with. The engine takes
 * the site key as its FIRST ARGUMENT, so this adapter passes the one this page was
 * actually rendered for and the scoping question never arises.
 *
 * Same-origin for the same reason the feedback beacon is: a proxied help centre
 * cannot reach the OpsIQ host cross-origin without failing CORS. */
/* PHASE_HC_SSO_HANDOFF (2026-08-24) — consume a seamless sign-in deep link ON
 * the help centre itself. When the portal runs in shared-HC mode its preflight
 * forwards the platform hook's ?identity_token here instead of rendering the
 * Portal landing to consume it in JS (the flash the owner kept seeing). This
 * page runs under the full bootstrap with the shared session, so the same
 * identity layer the portal_me AJAX uses can verify the HMAC token, burn its
 * single-use jti and sign the customer in via ClientAuth — then the URL is
 * cleaned so the token never lingers in the address bar or history. An
 * invalid/expired token simply lands the visitor on /hc signed out. */
if (!empty($_GET['identity_token']) && empty($_GET['_hcajax']) && !headers_sent()) {
    try {
        /* Public entry points boot with OPSIQ_EMBED_NO_SESSION, so no session
         * exists yet for a first-time visitor — start it explicitly for this
         * authenticated path (init.php already configured the portal session
         * name + cookie params; same precedent as the sign-in entry below). */
        if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
        foreach (['/opsiq/opsiq.portal_experience.php',
                  '/opsiq/includes/customer_identity_token_verify.php',
                  '/opsiq/opsiq.portal_identity.php'] as $__hoRel) {
            $__hoF = $_opsiqRoot . $__hoRel;
            if (is_file($__hoF)) { try { require_once $__hoF; } catch (\Throwable $e) {} }
        }
        if (function_exists('opsiq_portal_identity_current')) {
            opsiq_portal_identity_current();
        }
    } catch (\Throwable $e) {
        error_log('[opsiq][hc][sso-handoff] ' . $e->getMessage());
    }
    /* Clean, BROWSER-FACING /hc URL — hc_login_return_url() already strips the
     * credential/internal params (identity_token, site_key, routing junk) and
     * respects the forwarded custom-domain base, so the address bar never shows
     * the token or the internal /hc/<slug> path. */
    $__hoClean = function_exists('hc_login_return_url') ? hc_login_return_url() : hc_u('');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('X-Robots-Tag: noindex');
    header('Location: ' . $__hoClean, true, 302);
    exit;
}
if (!empty($_GET['_hcajax']) && !empty($_GET['_widget_identity'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('X-Robots-Tag: noindex');
    $__wiMe = null;
    try {
        if (!function_exists('opsiq_portal_identity_for_workspace')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_identity.php';
            if (is_file($__f)) require_once $__f;
        }
        if (function_exists('opsiq_portal_identity_for_workspace')) {
            $__wiMe = opsiq_portal_identity_for_workspace((string)$_siteKey);
        }
    } catch (\Throwable $e) { $__wiMe = null; }
    if (!is_array($__wiMe) || empty($__wiMe['signed_in'])) {
        http_response_code(401);
        echo json_encode(['success' => false, 'login_required' => true, 'error' => 'login_required']);
        exit;
    }
    $__wiToken = function_exists('opsiq_portal_mint_widget_identity_token_for_site')
        ? opsiq_portal_mint_widget_identity_token_for_site((string)$_siteKey, $__wiMe) : '';
    if ($__wiToken === '') {
        http_response_code(503);
        echo json_encode(['success' => false, 'error' => 'identity_unavailable']);
        exit;
    }
    echo json_encode(['success' => true, 'identity_token' => $__wiToken, 'customer' => $__wiMe]);
    exit;
}
/* PHASE_HC_NAV_ME_2026-08-24 — "is the reader signed in, and what do we call them?"
 * for the NAVIGATION only.
 *
 * ASKED FOR AFTER THE PAGE, NOT BAKED INTO IT. Rendering the name server-side is
 * the obvious way and the wrong one: help.php sends no Cache-Control of its own,
 * so the page is only uncached because Cloudflare currently calls it DYNAMIC. One
 * cache rule in front of it and a signed-in visitor's name would be served to
 * everyone who followed. Resolving identity on every page load would also put a
 * session lookup in front of an overwhelmingly signed-OUT audience.
 *
 * It answers for the CALLER'S OWN session and takes no parameters: there is
 * nothing here to enumerate, and no token is minted (the widget endpoint above
 * does that, and needs to). The name is the only field that leaves. */
if (!empty($_GET['_hcajax']) && !empty($_GET['_me'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
    header('X-Robots-Tag: noindex');
    $__me = null;
    try {
        if (!function_exists('opsiq_portal_identity_for_workspace')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_identity.php';
            if (is_file($__f)) require_once $__f;
        }
        if (function_exists('opsiq_portal_identity_for_workspace')) {
            $__me = opsiq_portal_identity_for_workspace((string)$_siteKey);
        }
    } catch (\Throwable $e) { $__me = null; }
    $__signedIn = is_array($__me) && !empty($__me['signed_in']);
    $__acctUrl  = '';
    if ($__signedIn) {
        if (!function_exists('opsiq_portal_public_base')) {
            $__pe = ($GLOBALS['_opsiqRoot'] ?? dirname(__FILE__)) . '/opsiq/opsiq.portal_experience.php';
            if (is_file($__pe)) { try { require_once $__pe; } catch (\Throwable $e) {} }
        }
        if (function_exists('opsiq_portal_public_base')) {
            try { $__acctUrl = (string)opsiq_portal_public_base((string)$_siteKey); }
            catch (\Throwable $e) { $__acctUrl = ''; }
        }
    }
    echo json_encode([
        'signed_in' => $__signedIn,
        'name'      => $__signedIn ? trim((string)($__me['name'] ?? '')) : '',
        'url'       => $__acctUrl,
    ]);
    exit;
}
if (!empty($_GET['_hcajax']) && !empty($_GET['_discussion'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('X-Robots-Tag: noindex');
    header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
    header('Pragma: no-cache');
    header('Vary: Cookie, X-Forwarded-Host', false);

    $__dcOut = static function (array $p): void { echo json_encode($p); exit; };

    if (!class_exists('\\OpsIQ\\Kb\\ArticleDiscussion')) {
        $__dcOut(['success' => false, 'error' => 'unavailable']);
    }
    /* The toggle is authoritative on the server. A reader who forges the request
     * still gets nothing when the operator has discussions switched off. */
    $__dcSet = HelpCenter::getSettings((string)$_siteKey);
    $__dcOn  = !in_array(strtolower(trim((string)($__dcSet['hc_comments_enabled'] ?? ''))), ['', '0', 'off', 'false', 'no'], true);
    if (!$__dcOn) {
        $__dcOut(['success' => true, 'enabled' => false, 'comments' => [], 'count' => 0, 'signed_in' => false]);
    }

    $__dcIn = json_decode((string)file_get_contents('php://input'), true);
    if (!is_array($__dcIn)) $__dcIn = $_POST;
    $__dcOp  = strtolower(trim((string)($__dcIn['op'] ?? 'thread')));
    $__dcArt = max(0, (int)($__dcIn['article_id'] ?? 0));

    /* IDENTITY IS THE PORTAL'S, and only the portal's. There is no second sign-in
     * surface in the help centre — the owner's ONE SSO contract. A signed-out reader
     * may READ the thread and may do nothing else. */
    $__dcMe = null;
    try {
        if (!function_exists('opsiq_portal_identity_current')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_identity.php';
            if (is_file($__f)) require_once $__f;
        }
        if (function_exists('opsiq_portal_identity_for_workspace')) {
            $__id = opsiq_portal_identity_for_workspace((string)$_siteKey);
            if (is_array($__id) && !empty($__id['signed_in']) && (int)($__id['id'] ?? 0) > 0) {
                $__dcMe = [
                    'id'    => (int)$__id['id'],
                    'email' => strtolower(trim((string)($__id['email'] ?? ''))),
                    'name'  => trim((string)($__id['name'] ?? '')),
                ];
            }
        }
    } catch (\Throwable $e) { $__dcMe = null; }

    /* CSRF. The token is minted with the thread read and required on every write.
     *
     * 2026-08-24 — this used to start a session for EVERY reader. The comment
     * said it "costs a session only once the reader has actually loaded a
     * discussion", which is true and was still far too many: every anonymous
     * visitor and every bot that opened an article got a session file. Measured
     * on the live install: 51,599 session files accumulated in two days, of
     * which exactly 9 held any identity — `_opsiq_hc_discussion_csrf` was in
     * 25,583 of them.
     *
     * An anonymous reader can never USE this token. Line ~8450 only returns it
     * when $__dcMe is set, and the first thing every mutating branch below does
     * is reject a caller without $__dcMe ('sign_in_required') — before the token
     * is even compared. So minting it for a signed-out reader produced a session
     * that was, by construction, unusable.
     *
     * Gate both the session and the mint on $__dcMe: identical behaviour for
     * anyone who can actually post, and no session at all for anyone who can't.
     * This is the same lazy-session principle as PHASE_NO_ORPHAN_SESSION in
     * bootstrap/init.php — this call site simply predated it. */
    $__dcTokKey = '_opsiq_hc_discussion_csrf';
    $__dcTok = '';
    if ($__dcMe) {
        if (session_status() !== PHP_SESSION_ACTIVE && !headers_sent()) { @session_start(); }
        $__dcTok = (string)($_SESSION[$__dcTokKey] ?? '');
        if (!preg_match('/^[a-f0-9]{64}$/', $__dcTok)) {
            try { $__dcTok = bin2hex(random_bytes(32)); }
            catch (\Throwable $e) { $__dcTok = hash('sha256', uniqid('', true) . microtime(true)); }
            $_SESSION[$__dcTokKey] = $__dcTok;
        }
    }

    if ($__dcOp === 'thread') {
        $__dcRes = \OpsIQ\Kb\ArticleDiscussion::thread((string)$_siteKey, $__dcArt, $__dcMe ?? [], false);
        $__dcRes['enabled']    = true;
        $__dcRes['csrf_token'] = $__dcMe ? $__dcTok : '';
        $__dcRes['me']         = $__dcMe ? ['name' => $__dcMe['name']] : null;
        $__dcOut($__dcRes);
    }

    /* --- everything below MUTATES --- */
    if (!$__dcMe) {
        $__dcOut(['success' => false, 'error' => 'sign_in_required']);
    }
    /* ORIGIN CHECK, LOADED RATHER THAN HOPED FOR.
     *
     * This was written as `if (function_exists(...) && !check())`, which reads like
     * caution and behaves like an off switch: help.php bootstraps the autoloader only,
     * so the function was NOT defined here and the guard never ran once. CSRF and the
     * signed-in requirement were carrying the whole load.
     *
     * Load the module, and if it cannot be loaded FAIL CLOSED. A defence that silently
     * skips itself is worse than no defence, because it reads as covered. */
    if (!function_exists('opsiq_ajax_origin_is_same_site')) {
        $__f = $_opsiqRoot . '/opsiq/opsiq.ajax_origin_is_same_site.php';
        if (is_file($__f)) { try { require_once $__f; } catch (\Throwable $e) {} }
    }
    if (!function_exists('opsiq_ajax_origin_is_same_site') || !opsiq_ajax_origin_is_same_site()) {
        $__dcOut(['success' => false, 'error' => 'invalid_origin']);
    }
    $__dcGiven = trim((string)($__dcIn['csrf_token'] ?? $_SERVER['HTTP_X_OPSIQ_KB_CSRF'] ?? ''));
    if ($__dcGiven === '' || !hash_equals($__dcTok, $__dcGiven)) {
        $__dcOut(['success' => false, 'error' => 'csrf']);
    }

    switch ($__dcOp) {
        case 'create':
            $__dcOut(\OpsIQ\Kb\ArticleDiscussion::create(
                (string)$_siteKey, $__dcArt, $__dcMe,
                (string)($__dcIn['body'] ?? ''), max(0, (int)($__dcIn['parent_id'] ?? 0))
            ));
        case 'edit':
            $__dcOut(\OpsIQ\Kb\ArticleDiscussion::edit(
                (string)$_siteKey, max(0, (int)($__dcIn['comment_id'] ?? 0)), $__dcMe,
                (string)($__dcIn['body'] ?? '')
            ));
        case 'remove':
            $__dcOut(\OpsIQ\Kb\ArticleDiscussion::removeOwn(
                (string)$_siteKey, max(0, (int)($__dcIn['comment_id'] ?? 0)), $__dcMe
            ));
        case 'report':
            $__dcOut(\OpsIQ\Kb\ArticleDiscussion::report(
                (string)$_siteKey, max(0, (int)($__dcIn['comment_id'] ?? 0)), $__dcMe,
                (string)($__dcIn['reason'] ?? 'other'), (string)($__dcIn['details'] ?? '')
            ));
        case 'subscribe':
            $__dcOut(\OpsIQ\Kb\ArticleDiscussion::subscribe(
                (string)$_siteKey, $__dcArt, $__dcMe, !empty($__dcIn['enabled'])
            ));
    }
    $__dcOut(['success' => false, 'error' => 'unknown_op']);
}
if (!empty($_GET['_hcajax'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('X-Robots-Tag: noindex');
    $viewHtml = hc_build_view();
    echo json_encode([
        'ok'     => true,
        'title'  => $pageTitle,
        'html'   => $viewHtml,
        'view'   => $_viewType,
        'isHome' => $_isHome,
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

/* ── Full HTML render ────────────────────────────────────────────────────── */
$_viewHtmlForPage = hc_build_view();

/* PHASE_HC_SEO_TITLE — build the page <title> + description. These were referenced
 * in <head> but NEVER assigned, so every page rendered an EMPTY <title> (the
 * browser fell back to showing the URL). Compose a real, page-specific title.
 * NOTE: use RAW setting values here — the <head> escapes via opsiq_h() at output,
 * so feeding it the already-escaped $siteName would double-encode entities. */
$__seoSite = trim((string)($_settings['site_name'] ?? 'Help Center'));
if ($__seoSite === '') $__seoSite = 'Help Center';
if (!empty($_article) && is_array($_article)) {
    /* NEVER name this $__t. $__t is the i18n translation CLOSURE defined at the top of
     * this file, and this block runs in global scope — assigning the title to it
     * replaced the closure with a string. hc_render_nav() imports $__t via `global` and
     * calls $__t('language','Language'), so every direct load of an article URL died
     * with "Call to undefined function <the article title>()" and rendered nav-only.
     * It only bit direct loads: in-app clicks fetch ?_hcajax=1, which never renders nav. */
    $__artTitle = trim((string)($_article['page_title'] ?? ''));
    $seoTitle = \OpsIQ\Kb\HcSeo::title($__artTitle, $__seoSite, $_settings);
    $seoDescription = trim((string)($_article['excerpt'] ?? ''));
    /* PHASE_HC_ACCESS_2026-08-17 — a customers-only article for a signed-out reader must not
     * quote its opening in <meta description>/og/twitter (that IS the body, 300 chars of it,
     * and it is what a search engine and a link preview would show), and must not be
     * indexed. The title stays: discovery is the point. */
    if (!\OpsIQ\Kb\HelpCenter::canReadArticle($_article)) {
        $seoDescription = $__t('gate_body', 'This article is available to signed-in customers. Sign in to read the full answer.');
        $GLOBALS['__hcGatedNoIndex'] = true;
    }
} elseif (!empty($_category) && is_array($_category)) {
    $__c = trim((string)($_category['name'] ?? ''));
    $seoTitle = \OpsIQ\Kb\HcSeo::title($__c, $__seoSite, $_settings);
    /* PHASE_HC_SEO_I18N_2026-08-15 — THE FOUR STRINGS GOOGLE READS.
     * These were English literals concatenated inline, so every locale's <title>,
     * <meta description> and og:description shipped in English no matter what the
     * visitor's language was — the widest-reach untranslated copy in the product, and
     * the one nobody sees while browsing because it lives in <head>. */
    $seoDescription = trim((string)($_category['description'] ?? ''))
        ?: sprintf($__t('seo_cat_desc', 'Browse %s help articles.'), $__c);
} elseif (trim((string)($_query ?? '')) !== '') {
    $seoTitle = \OpsIQ\Kb\HcSeo::title(sprintf($__t('seo_search_title', 'Search: %s'), trim((string)$_query)), $__seoSite, $_settings);
    $seoDescription = sprintf($__t('seo_search_desc', 'Search results for “%s”.'), trim((string)$_query));
} else {
    /* PHASE_HC_SEO_2026-08-27 — the home page gets a TITLE, not a label.
     * Owner: *"right now the home uses the site name as TITLE FOR HOME, wrong."* */
    $seoTitle = \OpsIQ\Kb\HcSeo::homeTitle($__seoSite, $_settings, (string)($_settings['text_hero_sub'] ?? ''), $__t);
    $seoDescription = trim((string)($_settings['seo_description_home'] ?? ''))
        ?: trim((string)($_settings['text_hero_sub'] ?? ''))
        ?: sprintf($__t('seo_home_desc', 'Help, guides and answers for %s.'), $__seoSite);
}
if (trim((string)$seoDescription) === '') $seoDescription = $__seoSite;
/* PHASE_HC_SEO_2026-08-27 — 160 at a WORD boundary, not 300 mid-word.
 * mb_strimwidth cut wherever the character budget ran out, so a live article's
 * description ended "…based on their account activ…". Google shows about 160 anyway, so
 * the extra 140 characters were never displayed and the damage was all that shipped. */
$seoDescription = \OpsIQ\Kb\HcSeo::clampDescription((string)$seoDescription);
/* PHASE_HELP_PRETTY_URL / SEO — THE CANONICAL MUST NAME *THIS* PAGE.
 *
 * BUG THIS FIXES (pre-existing, and it was quietly fatal): the canonical was
 * built by opsiq_public_canonical_url() with no arguments, and that function
 * strtok()s the query string off REQUEST_URI. So an article page emitted
 * <link rel="canonical" href=".../help/<slug>"> — every article, every category
 * and every search page declared itself a DUPLICATE OF THE HOME PAGE. Google
 * honours that and drops the duplicates, so the entire knowledge base was
 * telling search engines not to index a single article.
 *
 * Now the canonical carries the parameter that actually identifies the page, and
 * always uses the pretty /help/<slug> address, so the legacy /help.php/<slug>
 * (which keeps working for anyone who already embedded it) consolidates onto one
 * URL instead of being indexed as a second copy.
 *
 * Search RESULT pages are never canonical content: they are noindexed and point
 * at the home page. Indexing them is how a help center ends up with thousands of
 * near-empty "results for xyz" pages in Google. */
$__canonParams = [];
$_isSearchPage = ($_query !== '');
if ($_articleSlug !== '')   $__canonParams['article'] = $_articleSlug;
elseif ($_catSlug !== '')   $__canonParams['cat']     = $_catSlug;

$__canonPath = $_prettyHelp !== ''
    ? $baseUrl . $_prettyHelp
    : (function_exists('opsiq_public_canonical_url') ? opsiq_public_canonical_url() : '');

$seoCanonical = $__canonPath . ($__canonParams ? '?' . http_build_query($__canonParams) : '');

/* PHASE_HC_I18N_SEO (I3) — hreflang alternates + x-default + per-locale canonical.
 *
 * Opt-in (`i18n_seo_alternates`, default OFF): with it off NOTHING below changes,
 * so $seoCanonical stays exactly what it was and no new tags are emitted.
 *
 * The canonical above already resolves to the RIGHT HOST on every path — the
 * own-domain 301 (PHASE_HC_OWN_DOMAIN_SEO, which refuses platform-owned hosts) and
 * the cloud /help/<slug> → claimed-subdomain 301 both land here — so building the
 * alternates off it means they inherit that host for free and can never advertise
 * an address that redirects.
 *
 * SLUGS STAY IN THE SOURCE LANGUAGE (deliberate — translating URLs breaks every
 * existing link and the proxy), so a locale variant is the same path + ?lang=<loc>.
 *
 * Emitted only where it is TRUE: search pages and the embed render are noindexed,
 * and pointing hreflang at a noindexed URL is a contradiction Google reports as an
 * error. A single-language help center emits nothing either — a lone self-
 * referencing alternate is noise. */
$_seoAlternates   = [];
$_seoXDefault     = '';
$__i18nSeoOn = (!empty($_i18nOn)
    && trim((string)($_settings['i18n_seo_alternates'] ?? '')) !== ''
    && !in_array(strtolower((string)$_settings['i18n_seo_alternates']), ['0', 'off', 'false', 'no'], true));
if ($__i18nSeoOn && empty($_isSearchPage) && empty($_embed)
    && is_array($_i18nLocales) && count($_i18nLocales) > 1 && $seoCanonical !== '') {
    $__altUrl = static function (string $loc) use ($seoCanonical, $_i18nSource): string {
        /* The source language is the bare URL — it is what already ranks, and adding
         * ?lang=en to it would mint a second address for the same page. */
        if ($loc === $_i18nSource) return $seoCanonical;
        return $seoCanonical . (strpos($seoCanonical, '?') !== false ? '&' : '?') . 'lang=' . rawurlencode($loc);
    };
    foreach ($_i18nLocales as $__loc) {
        $__loc = (string)$__loc;
        if ($__loc === '') continue;
        $_seoAlternates[$__loc] = $__altUrl($__loc);
    }
    /* x-default = the source language: the version to serve a visitor whose language
     * we do not publish. */
    $_seoXDefault = $__altUrl($_i18nSource);
    /* SELF-REFERENCING canonical. This is the actual behaviour switch: with the flag
     * OFF a French page canonicalises to the English URL and Google indexes only the
     * English one. With it ON each locale is its own canonical and can rank in its
     * own language — which is the entire point of translating, but only correct once
     * the translations are complete. */
    if ($_locale !== $_i18nSource && isset($_seoAlternates[$_locale])) {
        $seoCanonical = $_seoAlternates[$_locale];
    }
}
/* ══════════════════════════════════════════════════════════════════════════════
   PHASE_HC_SEO_2026-08-27 — STRUCTURED DATA, AND THE SHARE CARD.

   `$seoSchemaJson` has been initialised to '' here for as long as it has existed and
   NOTHING ever assigned to it — measured on live pages, every one carried zero
   JSON-LD. So a help center was invisible to every rich result Google offers for this
   exact content: breadcrumbs, FAQ answers, article dates, the sitelinks search box.

   The graph is built in HcSeo so it can be tested without rendering a page. This block
   only gathers what the current route knows.
   ══════════════════════════════════════════════════════════════════════════════ */
if (!isset($seoSchemaJson)) $seoSchemaJson = '';

/* THE SHARE IMAGE, in the order an operator would expect: the one they chose for
 * sharing, then the hero photograph, then the logo. Without any of them a pasted link
 * is a blank card, which is what every workspace had. */
$__ogImage = trim((string)($_settings['seo_og_image'] ?? ''));
if ($__ogImage === '') $__ogImage = trim((string)($_settings['hero_bg_image'] ?? ''));
if ($__ogImage === '') $__ogImage = trim((string)($_settings['logo_url'] ?? ''));
if ($__ogImage !== '' && !preg_match('~^(https?://|/)~i', $__ogImage)) $__ogImage = '';
if ($__ogImage !== '') $__ogImage = hc_asset_url($__ogImage);
if ($__ogImage !== '' && strncmp($__ogImage, '/', 1) === 0) $__ogImage = rtrim($baseUrl, '/') . $__ogImage;

if (class_exists('\OpsIQ\Kb\HcSeo')) {
    /* Breadcrumbs mirror what the page actually shows, so the trail Google renders and
     * the trail a visitor clicks are the same one. */
    $__crumbs = [['name' => (string)($_settings['site_name'] ?? 'Help Center'), 'url' => rtrim($helpBase, '/')]];
    if (!empty($_category) && is_array($_category)) {
        $__crumbs[] = ['name' => (string)($_category['name'] ?? ''), 'url' => $seoCanonical];
    }
    if (!empty($_article) && is_array($_article)) {
        $__aCat = '';
        if (!empty($_article['category_id']) && function_exists('hc_category_by_id')) {
            $__c2 = hc_category_by_id((int)$_article['category_id']);
            $__aCat = trim((string)($__c2['name'] ?? ''));
            if ($__aCat !== '') $__crumbs[] = ['name' => $__aCat, 'url' => ''];
        }
        $__crumbs[] = ['name' => (string)($_article['page_title'] ?? ''), 'url' => $seoCanonical];
    }

    $__art = [];
    if (!empty($_article) && is_array($_article) && \OpsIQ\Kb\HelpCenter::canReadArticle($_article)) {
        $__pub = trim((string)($_article['published_at'] ?? $_article['created_at'] ?? ''));
        $__mod = trim((string)($_article['updated_at'] ?? ''));
        $__art = [
            'title'     => (string)($_article['page_title'] ?? ''),
            'published' => $__pub !== '' ? date('c', strtotime($__pub)) : '',
            'modified'  => $__mod !== '' ? date('c', strtotime($__mod)) : '',
            'section'   => $__aCat ?? '',
        ];
    }

    /* The FAQ block's own rows, only when this page renders them — describing questions
     * a visitor cannot see is what earns a manual action, not a rich result. */
    $__faqRows = [];
    foreach ((array)($GLOBALS['__hcFaqRendered'] ?? []) as $__fr) {
        $__faqRows[] = ['q' => (string)($__fr['q'] ?? ''), 'a' => (string)($__fr['a'] ?? '')];
    }

    $seoSchemaJson = \OpsIQ\Kb\HcSeo::graph([
        'page'        => !empty($_article) ? 'article' : (!empty($_category) ? 'category' : 'home'),
        'url'         => $seoCanonical,
        'siteName'    => (string)($_settings['site_name'] ?? 'Help Center'),
        'siteUrl'     => rtrim($baseUrl, '/'),
        'searchUrl'   => rtrim($helpBase, '/') . '?q={search_term_string}',
        'logo'        => trim((string)($_settings['logo_url'] ?? '')) !== '' ? hc_asset_url((string)$_settings['logo_url']) : '',
        'image'       => $__ogImage,
        'locale'      => (string)$_locale,
        'title'       => $seoTitle,
        'description' => $seoDescription,
        'breadcrumbs' => $__crumbs,
        'article'     => $__art,
        'faq'         => $__faqRows,
    ], $_settings);
}
/* Optional favicon (Site Config). */
$faviconUrl = trim((string)($_settings['favicon_url'] ?? ''));
if ($faviconUrl !== '' && !preg_match('~^(https?://|/)~i', $faviconUrl)) $faviconUrl = '';
/* PHASE_HC_ICON_ANYFORMAT — load only the icon webfonts a category actually uses. */
/* PHASE_HC_FOOTER_BRAND — footer payment badges may be icon-font classes, and the
 * loader only ships a webfont it can see a use for. Feed them in as pseudo-categories
 * or the glyphs render as empty boxes on a site whose categories use a different set
 * (or no icons at all). */
$__iconFontProbe = $_categories ?? [];
foreach ($footerPayment as $__pmi) {
    if (!empty($__pmi['icon'])) $__iconFontProbe[] = ['icon' => $__pmi['icon']];
}
$iconFontLinks = function_exists('hc_icon_font_links') ? hc_icon_font_links($__iconFontProbe) : '';

?><!DOCTYPE html>
<?php /* PHASE_HC_WIDGET_STUDIO — the panel's scrollbar is the DOCUMENT's, so its
         styling and its inner-edge flip have to hang off <html>, not #hc-page. */ ?>
<?php /* PHASE_HC_I18N — the real language + direction. Not cosmetic: screen readers
         announce in it, browsers offer translation from it, and `dir` is what
         actually mirrors the page for Arabic/Hebrew/Persian/Urdu. */ ?>
<html lang="<?= hc_esc($_locale) ?>"<?= $_localeDir === 'rtl' ? ' dir="rtl"' : '' ?><?= !empty($_widget) ? ' class="hc-widget-doc"' : '' ?>>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= opsiq_h($seoTitle) ?></title>
<meta name="description" content="<?= opsiq_h($seoDescription) ?>">
<?php
/* PHASE_HC_SEO_2026-08-27 — one robots line, decided once.
 *   the workspace-wide switch  — "hidden from search engines" while it is being built
 *   the per-article gate       — a customers-only article stays discoverable by title
 *   otherwise                  — index, and ALLOW a large image preview and a full
 *                                snippet. Without max-image-preview:large Google shows
 *                                a thumbnail at best, which is most of the click. */
$__seoHidden = in_array(strtolower(trim((string)($_settings['seo_noindex'] ?? 'off'))), ['1', 'on', 'true', 'yes'], true);
if ($__seoHidden): ?><meta name="robots" content="noindex,nofollow">
<?php elseif (!empty($GLOBALS['__hcGatedNoIndex'])): ?><meta name="robots" content="noindex,follow">
<?php else: ?><meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1">
<?php endif; ?>
<?php /* The browser UI colour on mobile — the brand, so the chrome matches the page. */ ?>
<meta name="theme-color" content="<?= opsiq_h($brandColor) ?>">
<link rel="canonical" href="<?= opsiq_h($seoCanonical) ?>">
<?php /* PHASE_HC_I18N_SEO (I3) — every language of THIS page, plus x-default.
         Google requires the set to be reciprocal and to include a self-reference,
         which is why the current locale is emitted too rather than skipped. */
foreach ($_seoAlternates as $__hl => $__hu): ?>
<link rel="alternate" hreflang="<?= opsiq_h($__hl) ?>" href="<?= opsiq_h($__hu) ?>">
<?php endforeach; ?>
<?php if ($_seoXDefault !== ''): ?>
<link rel="alternate" hreflang="x-default" href="<?= opsiq_h($_seoXDefault) ?>">
<?php endif; ?>
<?php
/* PHASE_HELP_PRETTY_URL / SEO — what must NOT be indexed:
 *   - search RESULT pages: they generate an unbounded number of thin, duplicated
 *     pages ("results for xyz"), which is how a help center pollutes its own
 *     index and buries the real articles.
 *   - the EMBED render: it is the same content in a chrome-less iframe. The
 *     canonical already points at the real page, and noindex makes it explicit. */
if (!empty($_isSearchPage) || !empty($_embed)): ?>
<meta name="robots" content="noindex,follow">
<?php endif; ?>
<?php if ($darkEnabled && empty($_widget)): /* ── CUSTOM CHROME FILLS, SHARED WITH THE PORTAL.
   An operator's own nav or footer paints its light panels with GRADIENTS as often as with
   colours, and a gradient is a `background-image` — the same property their logo and badges
   arrive on, which is why no stylesheet can separate them. This is the runtime pass that can;
   see opsiq/assets/chrome-autodark-fills.js for the two CSS-only attempts that each broke the
   other half. Deferred: it needs the chrome in the DOM, and it watches data-theme itself, so
   the dark toggle below needs to know nothing about it.
   NOT IN WIDGET MODE — that panel has no custom chrome and no footer at all. */ ?>
<?php /* ⚠ hc_asset_url(), NOT a root-relative path. A Help Center on a custom domain
   (kb.nabtech.co) is a proxy for the PAGE only — /opsiq/assets/... does not exist on that
   host, so a relative src 404s and the pass silently never runs there. Every other sheet
   and script on this page is absolute for exactly this reason. Caught live: the script
   loaded on /hc and not on the main Help Center. */ ?>
<script src="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/chrome-autodark-fills.js')) ?>" defer></script>
<?php endif; ?>
<?php if ($darkEnabled): /* PHASE_HC_DARK — resolve the theme BEFORE first paint.
   This must stay inline and this early: load it after the stylesheet and a visitor
   who chose dark gets a white flash on every page. It is also the single place the
   OS preference is read, which is why the dark CSS carries no media query. */ ?>
<script id="hc-dark-boot">
(function(){
  var DEF = <?= json_encode($darkDefault) ?>;          /* auto | light | dark */
  var IS_WIDGET = <?= !empty($_widget) ? 'true' : 'false' ?>;
  var KEY = 'hc_theme';
  var root = document.documentElement;
  function osDark(){
    try { return window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches; }
    catch(e){ return false; }
  }
  /* PHASE_HC_XORIGIN_SYNC — in the widget panel the parent may live on a DIFFERENT
     origin (own-domain Help Center + opsiqai.com widget), so localStorage/cookie are
     NOT shared. The loader passes the parent's theme in the iframe URL.
     SEED it as the stored choice ONCE at boot rather than letting it win inside
     resolve(): resolve() is re-run by the storage listener, so a URL param that always
     won would snap the panel back and undo the visitor's own toggle. Seeding makes the
     parent's theme the starting point, and any later toggle simply overwrites it. */
  if (IS_WIDGET) {
    try { var _qp = new URLSearchParams(location.search).get('theme');
      if (_qp === 'dark' || _qp === 'light') localStorage.setItem(KEY, _qp); } catch(e){}
    /* CONSUME the param once. The loader stamps ?theme= into the iframe URL at panel
       CREATION, but the panel also navigates ITSELF — a language change is a real
       navigation, because content is translated server-side. That reload replays the
       same URL, so a param captured minutes ago was re-seeding as if it were fresh and
       overwriting a theme the visitor had picked in between, then reporting the stale
       value up to the host: toggle to dark, switch language, both snap back to light.
       Dropping it after the first read makes the seed what it claims to be — a starting
       point, not a standing instruction. Live changes still arrive by postMessage. */
    try {
      var _u = new URL(location.href);
      if (_u.searchParams.has('theme')) {
        _u.searchParams.delete('theme');
        history.replaceState(null, '', _u.toString());
      }
    } catch(e){}
  }

  function resolve(){
    var saved = null;
    try { saved = localStorage.getItem(KEY); } catch(e){}
    if (saved === 'dark' || saved === 'light') return saved;   /* the visitor's choice always wins */
    if (DEF === 'dark' || DEF === 'light') return DEF;
    return osDark() ? 'dark' : 'light';
  }
  function apply(t){ root.setAttribute('data-theme', t); }
  apply(resolve());

  /* Public API. The nav button, the widget header button and any snippet the
     operator pastes on their own page all go through this one function. */
  window.OpsIQHelpTheme = {
    get: function(){ return root.getAttribute('data-theme') || 'light'; },
    set: function(t){
      if (t !== 'dark' && t !== 'light') return;
      /* LOOP BREAKER — do NOT re-broadcast a theme we are already showing.
       * The page and the widget panel echo changes to each other (page -> panel via
       * postMessage, panel -> page via the -state reply). Without this guard that echo
       * never terminates: each side re-dispatches on every set, so a single toggle
       * ping-pongs forever and the UI flickers light/dark. With it, the echo dies the
       * moment both sides agree — one round trip, then silence. */
      if (this.get() === t) return;
      try { localStorage.setItem(KEY, t); } catch(e){}
      apply(t);
      try { window.dispatchEvent(new CustomEvent('opsiq-help-theme', {detail:{theme:t}})); } catch(e){}
    },
    toggle: function(){ this.set(this.get() === 'dark' ? 'light' : 'dark'); },
    /* Hand the choice back to the OS / the operator default. */
    reset: function(){ try { localStorage.removeItem(KEY); } catch(e){} apply(resolve()); }
  };

  /* Two documents, one choice.
   * Put the widget on the same page as the Help Center and you have TWO copies of
   * this script running (the page, and the panel's iframe). Same origin ⇒ same
   * localStorage, so without this they each keep their own idea of the theme and
   * only agree after a reload — they fight. The `storage` event fires in the OTHER
   * document whenever one of them writes, so both follow along live. */
  window.addEventListener('storage', function(e){
    if (e.key !== KEY) return;
    apply(resolve());
  });

  /* PHASE_HC_I18N_SYNC — the SAME "two documents, one choice" for LANGUAGE.
   * The panel iframe loads help.php?embed=1 with NO ?lang=, so it was picking the
   * default while the page showed French — the languages never agreed. Language is
   * SERVER-rendered (content is translated server-side), so syncing = (a) persist the
   * locale this document shows into the cookie [the server reads it] + localStorage
   * [fires the cross-document `storage` event], so the panel INHERITS it on load; and
   * (b) when the OTHER document's choice changes, RELOAD so it re-renders translated. */
  (function(){
    var LKEY = 'hc_lang';
    var CUR  = <?= json_encode((string)($_locale ?? '')) ?>;
    var SRC  = <?= json_encode((string)($_i18nSource ?? '')) ?>;
    var ON   = <?= !empty($_i18nOn) ? 'true' : 'false' ?>;
    if (!ON || !CUR) return;
    /* PHASE_HC_XORIGIN_SYNC — the panel does NOT join this channel.
       "Two documents, one choice" assumes the page and the panel share an origin.
       Cross-origin (own-domain Help Center + opsiqai.com panel) that is false: the
       panel's localStorage neighbours are not the host page, they are every OTHER
       opsiqai.com Help Center tab the visitor happens to have open. Left in, an
       unrelated tab writing hc_lang reloaded the panel into its language, and the
       panel then relayed that up to the host — the customer's page changed language
       with nobody touching it.
       The panel has two better sources and needs no third: ?lang= at boot (server
       rendered, no flash) and the host reload that follows any language change.
       Skipping the WRITE matters as much as the listener: the panel's language is a
       reflection of the HOST's choice, so persisting it on the widget origin would
       push that choice sideways into unrelated tabs. Language is persisted where it
       belongs, on the customer's own domain, by the host document. */
    if (IS_WIDGET) return;
    /* Persist what THIS document shows, so a ?lang= visit is remembered AND the panel
       inherits it. Guarded so it only writes on a real change (no needless events). */
    try { if (localStorage.getItem(LKEY) !== CUR) localStorage.setItem(LKEY, CUR); } catch(e){}
    try { document.cookie = 'hc_lang=' + CUR + ';path=/;max-age=31536000;SameSite=Lax'; } catch(e){}
    /* Live: the other document changed language -> reload this one in that language. */
    window.addEventListener('storage', function(e){
      if (e.key !== LKEY || !e.newValue || e.newValue === CUR) return;
      var u = new URL(window.location.href);
      if (e.newValue === SRC) u.searchParams.delete('lang'); else u.searchParams.set('lang', e.newValue);
      window.location.replace(u.toString());
    });
  })();

  /* The panel's header button lives on the CUSTOMER's page, outside this frame and
   * cross-origin, so it cannot touch this DOM. It asks over postMessage instead,
   * and we answer with our current theme so its glyph matches.
   * Safety: only `toggle` / `dark` / `light` are honoured, only from our direct
   * parent, and the reply carries nothing but a theme name — the parent origin is
   * the customer's own domain and cannot be whitelisted, so nothing sensitive is
   * ever sent and no other instruction is accepted. */
  if (IS_WIDGET) {
    window.addEventListener('message', function(e){
      if (e.source !== window.parent) return;
      var d = e.data;
      if (!d) return;
      if (d.type === 'opsiq-help-theme') {
        if (d.set === 'toggle') window.OpsIQHelpTheme.toggle();
        else if (d.set === 'dark' || d.set === 'light') window.OpsIQHelpTheme.set(d.set);
        return;
      }
    });
    var tell = function(){
      try { window.parent.postMessage({type:'opsiq-help-theme-state', theme: root.getAttribute('data-theme')}, '*'); } catch(e){}
    };
    tell();                                            /* on boot, so the glyph starts right */
    window.addEventListener('opsiq-help-theme', tell); /* and after every change */
  }

  /* Follow the OS live, but only while the visitor has not chosen for themselves. */
  if (DEF === 'auto') {
    try {
      var mq = window.matchMedia('(prefers-color-scheme:dark)');
      var onChange = function(){
        var saved = null; try { saved = localStorage.getItem(KEY); } catch(e){}
        if (saved !== 'dark' && saved !== 'light') apply(osDark() ? 'dark' : 'light');
      };
      if (mq.addEventListener) mq.addEventListener('change', onChange);
      else if (mq.addListener) mq.addListener(onChange);
    } catch(e){}
  }
})();
</script>
<?php endif; ?>
<link rel="sitemap" type="application/xml" title="<?= opsiq_h($__t("sitemap", "Sitemap")) ?>" href="<?= opsiq_h((function_exists("opsiq_public_base_url") ? rtrim(opsiq_public_base_url(), "/") : "") . "/sitemap.xml") ?>">
<?php if ($faviconUrl !== ''): ?>
<link rel="icon" href="<?= opsiq_h(hc_asset_url($faviconUrl)) ?>">
<link rel="apple-touch-icon" href="<?= opsiq_h(hc_asset_url($faviconUrl)) ?>">
<?php endif; ?>
<?php if ($iconFontLinks !== ''): echo $iconFontLinks; endif; ?>
<?php if (!empty($_fontHeadLinks)): echo $_fontHeadLinks; endif; ?>
<?php /* PHASE_HC_SEO_2026-08-27 — an article is not a "website".
         og:type drives what a platform renders: `article` unlocks the published and
         modified times below, which is what a link preview uses to show freshness. */ ?>
<meta property="og:type" content="<?= !empty($_article) ? 'article' : 'website' ?>">
<meta property="og:title" content="<?= opsiq_h($seoTitle) ?>">
<meta property="og:description" content="<?= opsiq_h($seoDescription) ?>">
<meta property="og:url" content="<?= opsiq_h($seoCanonical) ?>">
<meta property="og:site_name" content="<?= opsiq_h((string)($_settings['site_name'] ?? 'OpsIQ Help Center')) ?>">
<meta property="og:locale" content="<?= opsiq_h(str_replace('-', '_', (string)$_locale)) ?>">
<?php foreach ($_seoAlternates as $__ogl => $__ogu): if ($__ogl === $_locale || $__ogl === 'x-default') continue; ?>
<meta property="og:locale:alternate" content="<?= opsiq_h(str_replace('-', '_', (string)$__ogl)) ?>">
<?php endforeach; ?>
<?php if (!empty($_article) && !empty($__art['published'])): ?>
<meta property="article:published_time" content="<?= opsiq_h($__art['published']) ?>">
<?php endif; if (!empty($_article) && !empty($__art['modified'])): ?>
<meta property="article:modified_time" content="<?= opsiq_h($__art['modified']) ?>">
<?php endif; if (!empty($_article) && !empty($__art['section'])): ?>
<meta property="article:section" content="<?= opsiq_h($__art['section']) ?>">
<?php endif; ?>
<?php /* THE SHARE CARD. Without an image every pasted link renders as a bare line of
         text, and `summary` is the small card — the large one is only offered when
         there is actually an image to fill it. */ ?>
<?php if ($__ogImage !== ''): ?>
<meta property="og:image" content="<?= opsiq_h($__ogImage) ?>">
<meta name="twitter:image" content="<?= opsiq_h($__ogImage) ?>">
<?php endif; ?>
<meta name="twitter:card" content="<?= $__ogImage !== '' ? 'summary_large_image' : 'summary' ?>">
<meta name="twitter:title" content="<?= opsiq_h($seoTitle) ?>">
<meta name="twitter:description" content="<?= opsiq_h($seoDescription) ?>">
<?php $__twSite = ltrim(trim((string)($_settings['seo_twitter_site'] ?? '')), '@'); if ($__twSite !== ''): ?>
<meta name="twitter:site" content="@<?= opsiq_h($__twSite) ?>">
<?php endif; ?>
<?php if (!empty($seoSchemaJson)): ?>
<script type="application/ld+json"><?= $seoSchemaJson ?></script>
<?php endif; ?>
<link rel="sitemap" type="application/xml" title="<?= opsiq_h($__t("sitemap", "Sitemap")) ?>" href="<?= opsiq_h((function_exists("opsiq_public_base_url") ? rtrim(opsiq_public_base_url(), "/") : "") . "/sitemap.xml") ?>">
<style>
/* ── Reset & tokens ─────────────────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --brand:<?= $brandColor ?>;
  --br:<?= $brandRGB ?>;
  --brand-on:<?= $brandOn ?>;
  --btn:<?= $btnColor ?>;
  --btn-rgb:<?= $btnRGB ?>;
  --hero-base:<?= hc_esc($heroBase) ?>;
  --footer-font-weight:<?= hc_esc($_footerFontWeight) ?>;
  --nav-custom-bg:<?= hc_esc($_navResolvedBg) ?>;
  --nav-custom-text:<?= $_navTextColor !== '' ? hc_esc($_navTextColor) : 'inherit' ?>;
  --nav-custom-hover:<?= $_navHoverColor !== '' ? hc_esc($_navHoverColor) : 'var(--brand)' ?>;
  --footer-bg:<?= hc_esc($_footerBackground) ?>;
  --footer-font:<?= hc_esc($_footerFont) ?>;
  --footer-font-size:<?= hc_esc($_footerFontSize) ?>;
  /* PHASE_HC_BRAND_PARTNER_2026-08-27 — THE SECOND STOP OF A BRAND GRADIENT.
   *
   * Owner, on the nav CTA and a numbered icon tile: *"it uses the accent partially and
   * with a colour left in it, I don't know where it's coming from."*
   *
   * It was coming from a hardcoded hue. Sixteen gradients across six stylesheets were
   * built as `linear-gradient(…, var(--brand), color-mix(in srgb, var(--brand) N%,
   * #22d3ee))` — the operator's colour at one end and a fixed cyan, pink, purple or sky
   * at the other. On the indigo the sheets were designed against, the second stop reads
   * as a deeper shade of the same colour and nobody notices. On a warm brand it does
   * not: the owner's tan button ended sage, because tan mixed with cyan IS sage, and a
   * peach tile ended pink because peach mixed with #a855f7 IS pink. Half of every one of
   * those surfaces was a colour the operator never chose and could not reach.
   *
   * These two are the partner stop, derived from the brand itself. Nothing else may
   * name a hue inside a brand gradient.
   *
   * They are DEPTH, not a second hue: the same colour carried toward the neutral ink
   * the sheets already shade with. A rotation was tried first and rejected — it is
   * still a colour the operator did not pick, only one derived politely, and +26° off
   * a tan brand lands on yellow-green. Mixing toward #0f172a keeps the hue and reads
   * as a gradient OF the brand, which is what these surfaces were always drawing.
   *
   * It also keeps the white ink most of them carry legible, which a tint toward white
   * would have quietly cost at one end of every button.
   *
   * Plain color-mix, deliberately. The first attempt put a relative-colour upgrade
   * behind `@supports (color:hsl(from red h s l))` — the guard passed and the value
   * was still invalid, because `l` there is a NUMBER and the value said `calc(l + 7%)`.
   * An invalid var() substitution takes the whole `background` down with it, so the
   * nav CTA lost its gradient entirely and computed to `none`. A guard that tests
   * something other than the value it is guarding is not a guard. */
  --brand-2:color-mix(in srgb,var(--brand) 82%,#0f172a);
  --btn-2:color-mix(in srgb,var(--btn) 82%,#0f172a);
  /* Two of the sixteen were genuinely THREE-colour — the ribbon's top rule and the
   * gradient CTA band — and collapsing them to two stops would have flattened a
   * design rather than fixed a colour. This is the far stop of those. */
  --brand-3:color-mix(in srgb,var(--brand) 62%,#0f172a);
  --brand-5:rgba(<?= $brandRGB ?>,.05);
  --brand-10:rgba(<?= $brandRGB ?>,.10);
  --brand-15:rgba(<?= $brandRGB ?>,.15);
  --brand-20:rgba(<?= $brandRGB ?>,.20);
  --brand-30:rgba(<?= $brandRGB ?>,.30);
  --g50:#f8fafc;--g100:#f1f5f9;--g200:#e2e8f0;--g300:#cbd5e1;
  --g400:#94a3b8;--g500:#64748b;--g600:#475569;--g700:#334155;
  --g800:#1e293b;--g900:#0f172a;
  --font:<?= hc_esc($_globalFont) ?>;
  --font-heading:<?= $_headingFont !== '' ? hc_esc($_headingFont) : 'var(--font)' ?>;
  --mono:'SF Mono','Fira Code','Roboto Mono',monospace;
  --ease:cubic-bezier(.4,0,.2,1);
  --shadow-sm:0 1px 3px rgba(0,0,0,.07),0 1px 2px rgba(0,0,0,.05);
  --shadow-md:0 4px 16px -4px rgba(0,0,0,.11),0 2px 8px -2px rgba(0,0,0,.07);
  --shadow-lg:0 12px 40px -8px rgba(0,0,0,.16),0 4px 16px -4px rgba(0,0,0,.09);
  --shadow-brand:0 8px 24px -6px rgba(<?= $brandRGB ?>,.38);
  /* Theme tokens — overridden per layout */
  --card-bg:#fff;
  --card-border:var(--g200);
  --body-bg:var(--g50);
  --text-primary:var(--g900);
  --text-secondary:var(--g600);
  --text-muted:var(--g400);
  --hero-text:#fff;
  --hero-sub:rgba(255,255,255,.45);
  /* PHASE6_2026-08-07 — the one state the card palette has no word for: a
     destructive action that has ARMED itself, and a send that failed. Declared as
     tokens rather than written into the rules, so a layout or the Colour Studio can
     override them exactly like every other colour on this page. Nothing in the help
     centre may hardcode a colour at its use site. */
  --hc-danger:#b91c1c;
  --hc-danger-soft:#fef2f2;
  --hc-danger-line:#fca5a5;
  /* Text on a brand-coloured fill. The house convention wrote #fff literally at each
     use site; declaring it makes the one case that might need to differ (a pale brand)
     reachable from the Colour Studio instead of unreachable. */
  --btn-text:#fff;
  /* PHASE7_2026-08-07 — three tokens the article tags had always REFERENCED but that
     were never declared anywhere, so each one silently used its inline fallback. In
     light mode that looked correct; in dark mode `--card-2`'s fallback is a DARK
     translucent laid over an already-dark card, which erases the chip, and `--text-2`'s
     is a slate too dark to read on it. Declaring them is what makes the dark block
     below able to reach them at all. */
  --text-2:#475569;
  --card-2:rgba(15,23,42,.05);
  --brand-15:rgba(var(--br),.12);
}
html{scroll-behavior:smooth}
body{font-family:var(--font);color:var(--text-primary);background:var(--body-bg);
  line-height:1.6;font-size:15px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
/* PHASE_HC_CUSTOM_FONT — Headings font (falls back to --font when unset). */
.hc-hero h1,.hc-subhero-title,.hc-cat-pg-title,.hc-art-title,.hc-sec-label,.hc-home-cat-name,.hc-cat-name,.hc-pop-title,.hc-arow-title,.hc-kb-cat-name,.hc-body h2,.hc-body h3,.hc-body h4{font-family:var(--font-heading)}
a{color:var(--brand-ink,var(--brand));text-decoration:none}

/* ── AJAX page wrapper ──────────────────────────────────────────────────────── */
#hc-page{transition:opacity .12s var(--ease),transform .12s var(--ease)}
#hc-page.hc-loading{opacity:.6;pointer-events:none}
#hc-page.hc-out{opacity:0;transform:translateY(8px)}

/* ── Scroll progress ────────────────────────────────────────────────────────── */
<?php if (!$_embed): ?>
#hc-prog{position:fixed;top:0;left:0;height:2px;width:0;z-index:9999;
  background:linear-gradient(90deg,var(--brand),rgba(<?= $brandRGB ?>,.5));
  border-radius:0 2px 2px 0;transition:width .08s linear;pointer-events:none}
<?php endif; ?>

/* ── Sticky header ──────────────────────────────────────────────────────────── */
<?php if (!$_embed): ?>
.hc-hdr{position:sticky;top:0;z-index:200;
  background:rgba(255,255,255,.88);
  backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);
  border-bottom:1px solid var(--g200);box-shadow:0 1px 0 rgba(0,0,0,.04)}
.hc-hdr-inner{max-width:1160px;margin:0 auto;padding:0 28px;height:62px;
  display:flex;align-items:center;gap:18px}
.hc-logo{display:inline-flex;align-items:center;gap:10px;min-height:0;max-width:min(260px,46vw);
  color:var(--g900);white-space:nowrap;flex-shrink:0;letter-spacing:0;overflow:hidden;padding:0!important;background:transparent!important;border:0!important;box-shadow:none!important;border-radius:0!important}
.hc-logo img,.hc-logo-img{display:block;width:auto;height:auto;max-width:min(180px,42vw);max-height:40px;object-fit:contain;border-radius:0!important;background:transparent!important;box-shadow:none!important;border:0!important}
.hc-logo-name{font-weight:950;color:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hc-logo-default{display:inline-flex;align-items:center;gap:8px;font-weight:800;font-size:15.5px;color:var(--g900)}
.hc-logo-dot{display:inline-block;width:8px;height:8px;border-radius:50%;
  background:var(--brand);flex-shrink:0}
.hc-hdr-search{flex:1;max-width:420px;margin-left:auto;position:relative}
.hc-hdr-search-icon{position:absolute;left:11px;top:50%;transform:translateY(-50%);
  color:var(--g400);pointer-events:none;display:flex;z-index:1}
.hc-hdr-search input{width:100%;padding:8px 14px 8px 35px;
  background:var(--g100);border:1.5px solid var(--g200);border-radius:10px;
  font-size:13.5px;color:var(--g800);outline:none;transition:all .2s var(--ease);font-family:var(--font)}
.hc-hdr-search input:focus{background:#fff;border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-10)}
.hc-hdr-search input::placeholder{color:var(--g400)}
.hc-hdr-search-wrap{position:relative}
#hc-suggest-hdr{top:calc(100% + 6px)}
.hc-hdr-home{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;
  border-radius:9px;font-size:13px;font-weight:500;color:var(--g500);flex-shrink:0;
  transition:all .15s var(--ease);white-space:nowrap}
.hc-hdr-home:hover{background:var(--g100);color:var(--g900)}
.hc-hdr-home svg{opacity:.7}
<?php endif; ?>

/* ── Animated orbs ──────────────────────────────────────────────────────────── */
@keyframes hc-float{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(18px,-24px) scale(1.06)}66%{transform:translate(-14px,12px) scale(.96)}}
@keyframes hc-float2{0%,100%{transform:translate(0,0) scale(1)}40%{transform:translate(-22px,18px) scale(1.04)}70%{transform:translate(16px,-10px) scale(.98)}}
.hc-orb{position:absolute;border-radius:50%;pointer-events:none;filter:blur(48px)}
.hc-orb-1{width:480px;height:480px;top:-120px;left:-100px;
  background:radial-gradient(circle,rgba(<?= $brandRGB ?>,.28) 0%,transparent 68%);
  animation:hc-float 14s ease-in-out infinite}
.hc-orb-2{width:360px;height:360px;bottom:-80px;right:-60px;
  background:radial-gradient(circle,rgba(<?= $brandRGB ?>,.18) 0%,transparent 72%);
  animation:hc-float2 18s ease-in-out infinite}

/* ── Hero — nebula (default) ────────────────────────────────────────────────── */
.hc-hero{position:relative;overflow:hidden;
  background:linear-gradient(145deg,var(--hero-base) 0%,#0e0e22 45%,rgba(<?= $brandRGB ?>,.22) 100%);
  padding:88px 24px 110px;text-align:center}
.hc-hero::before{content:'';position:absolute;inset:0;pointer-events:none;
  background:radial-gradient(ellipse 90% 65% at 50% -10%,rgba(<?= $brandRGB ?>,.35) 0%,transparent 68%)}
.hc-hero::after{content:'';position:absolute;inset:0;pointer-events:none;
  background-image:radial-gradient(circle,rgba(255,255,255,.055) 1px,transparent 1px);
  background-size:30px 30px;opacity:.6}
.hc-hero-inner{position:relative;z-index:1;max-width:660px;margin:0 auto}
.hc-hero-pill{display:inline-flex;align-items:center;gap:8px;
  background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);
  border-radius:999px;padding:5px 16px;margin-bottom:26px;
  font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
  color:rgba(255,255,255,.65)}
.hc-hero-dot{width:6px;height:6px;border-radius:50%;background:var(--brand);
  box-shadow:0 0 8px var(--brand),0 0 16px rgba(<?= $brandRGB ?>,.4)}
.hc-hero h1{font-size:clamp(34px,5.5vw,58px);font-weight:800;color:var(--hero-text);
  line-height:1.14;margin-bottom:16px;letter-spacing:-.03em}
.hc-hero-sub{font-size:16px;color:var(--hero-sub);margin-bottom:38px;font-weight:400;line-height:1.6}
/* Frosted glass search */
.hc-srch-wrap{max-width:580px;margin:0 auto 32px;position:relative}
.hc-srch-glass-wrap{position:relative}
.hc-srch-glass{display:flex;align-items:center;
  background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.14);
  border-radius:16px;overflow:visible;
  backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);
  box-shadow:0 8px 36px rgba(0,0,0,.28),inset 0 1px 0 rgba(255,255,255,.1);
  transition:all .22s var(--ease)}
.hc-srch-glass:focus-within{border-color:rgba(255,255,255,.28);background:rgba(255,255,255,.12);
  box-shadow:0 8px 44px rgba(0,0,0,.36),0 0 0 3px rgba(<?= $brandRGB ?>,.28),inset 0 1px 0 rgba(255,255,255,.14)}
.hc-srch-ico{padding:0 14px 0 20px;color:rgba(255,255,255,.38);flex-shrink:0;display:flex}
.hc-srch-glass input{flex:1;padding:17px 0;background:transparent;border:none;
  font-size:16px;color:#fff;outline:none;font-family:var(--font)}
.hc-srch-glass input::placeholder{color:rgba(255,255,255,.32)}
.hc-srch-glass button{margin:8px;padding:9px 22px;
  background:var(--btn);border:none;border-radius:10px;
  color:#fff;font-size:14px;font-weight:700;cursor:pointer;
  font-family:var(--font);transition:all .18s var(--ease);white-space:nowrap;
  box-shadow:0 2px 10px rgba(<?= $btnRGB ?>,.45)}
.hc-srch-glass button:hover{filter:brightness(1.1);transform:translateY(-1px);
  box-shadow:0 8px 24px -6px rgba(<?= $btnRGB ?>,.55)}
</style>
<?php /* PHASE0.5_2026-08-08 — 3,510 bytes of static rules lifted to /opsiq/assets/hc-sheet-14.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-14.css')) ?>">
<style>

.hc-hero .hc-suggest-item:hover,.hc-hero .hc-suggest-item.hc-act{
  background:rgba(<?= $brandRGB ?>,.14);color:#fff}
.hc-suggest-portal#hc-suggest-hero .hc-suggest-item:hover,.hc-suggest-portal#hc-suggest-hero .hc-suggest-item.hc-act{
  background:rgba(<?= $brandRGB ?>,.14);color:#fff}
</style>
<?php /* PHASE0.5_2026-08-08 — 47,740 bytes of static rules lifted to /opsiq/assets/hc-sheet-1.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-1.css')) ?>">
<style>

a.hc-quick-tile:hover{transform:translateY(-3px);box-shadow:var(--shadow-md),0 0 0 3px var(--brand-10);border-color:rgba(<?= $brandRGB ?>,.34)}
</style>
<?php /* PHASE0.5_2026-08-08 — 44,047 bytes of static rules lifted to /opsiq/assets/hc-sheet-2.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-2.css')) ?>">
<style>

.hc-cat::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;
  background:linear-gradient(90deg,var(--brand),rgba(<?= $brandRGB ?>,.3));
  transform:scaleX(0);transform-origin:left;transition:transform .25s var(--ease)}
.hc-cat:hover{border-color:rgba(<?= $brandRGB ?>,.28);
  box-shadow:var(--shadow-md),0 0 0 3px var(--brand-10);transform:translateY(-4px)}
.hc-cat:hover::before{transform:scaleX(1)}
.hc-cat-ico{width:46px;height:46px;border-radius:13px;
  background:var(--brand-10);border:1px solid var(--brand-15);
  display:flex;align-items:center;justify-content:center;font-size:22px;margin-bottom:15px}
.hc-cat-name{font-weight:700;font-size:15px;color:var(--text-primary);margin-bottom:5px}
.hc-cat-desc{font-size:12.5px;color:var(--text-secondary);line-height:1.55;margin-bottom:16px;min-height:18px}
.hc-cat-foot{display:flex;align-items:center;justify-content:space-between}
.hc-cat-badge{display:inline-flex;align-items:center;gap:4px;
  background:var(--g100);border-radius:999px;padding:3px 10px;
  font-size:11.5px;font-weight:600;color:var(--g500)}
.hc-cat-arr{color:var(--g300);transition:all .2s var(--ease)}
.hc-cat:hover .hc-cat-arr{color:var(--brand-ink,var(--brand));transform:translateX(3px)}

/* ── Article list rows ──────────────────────────────────────────────────────── */
.hc-alist{display:flex;flex-direction:column;gap:6px}
.hc-arow{display:flex;align-items:center;gap:16px;
  background:var(--card-bg);border:1.5px solid var(--card-border);
  border-radius:13px;padding:16px 20px;transition:all .18s var(--ease);
  color:inherit;text-decoration:none;position:relative}
.hc-arow:hover{border-color:rgba(<?= $brandRGB ?>,.22)}
</style>
<?php /* PHASE0.5_2026-08-08 — 23,949 bytes of static rules lifted to /opsiq/assets/hc-sheet-3.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-3.css')) ?>">
<style>

.hc-pop-item:hover{border-color:rgba(<?= $brandRGB ?>,.22);box-shadow:var(--shadow-sm)}
.hc-pop-item .hc-pop-body{transition:transform .22s var(--ease)}
.hc-pop-item:hover .hc-pop-body{transform:translateX(3px)}
</style>
<?php /* PHASE0.5_2026-08-08 — 7,468 bytes of static rules lifted to /opsiq/assets/hc-sheet-7.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-7.css')) ?>">
<style>

.hc-body a{color:var(--brand-ink,var(--brand));border-bottom:1px solid rgba(<?= $brandRGB ?>,.28);
  transition:border-color .15s;text-decoration:none}
.hc-body a:hover{border-color:var(--brand)}
.hc-body code{background:var(--g100);border:1px solid var(--g200);
  padding:2px 7px;border-radius:6px;font-size:13.5px;font-family:var(--mono);color:var(--g800)}
.hc-body pre{background:#12121e;border-radius:12px;padding:20px 22px;
  overflow-x:auto;margin-bottom:22px;border:1px solid rgba(255,255,255,.06)}
.hc-body pre code{background:none;border:none;padding:0;
  font-size:13.5px;color:#c9d1d9;line-height:1.75}
.hc-body blockquote{border-left:3.5px solid var(--brand);padding:12px 18px;
  margin-bottom:18px;background:var(--brand-5);border-radius:0 10px 10px 0;
  font-style:italic;color:var(--g600)}
.hc-body hr{border:none;border-top:1px solid var(--card-border);margin:28px 0}
.hc-body table{width:100%;border-collapse:collapse;margin-bottom:22px;font-size:14px;border-radius:10px;overflow:hidden}
.hc-body th{background:var(--g100);padding:11px 14px;text-align:left;
  font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;
  color:var(--g500);border-bottom:2px solid var(--g200)}
.hc-body td{padding:11px 14px;border-bottom:1px solid var(--g200)}
.hc-body img{max-width:100%;border-radius:10px;margin-bottom:18px;border:1px solid var(--g200)}
/* Feedback */
.hc-feedback{margin-top:40px;padding:24px;
  background:linear-gradient(135deg,var(--g50) 0%,rgba(<?= $brandRGB ?>,.03) 100%);
  border:1.5px solid var(--card-border);border-radius:14px;text-align:center;
  transition:opacity .2s var(--ease)}
</style>
<?php /* PHASE0.5_2026-08-08 — 8,412 bytes of static rules lifted to /opsiq/assets/hc-sheet-8.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-8.css')) ?>">
<style>

/* Payment badges: operator images on a neutral plate, so a dark logo still reads on
   a dark footer and a light one on a light footer. */
/* A fixed grid, not a wrapping flex row: even plates in even columns is what makes
   a payment strip look deliberate rather than tacked on. */
<?php
/* Plate = a white card behind each mark (how card schemes are usually shown).
   Bare = the glyph alone, inheriting the footer's own ink. Cell width follows the
   glyph size so the grid stays tight either way. */
$__payCell = $footerPaymentStyle === 'bare' ? $footerPaymentSize + 14 : $footerPaymentSize + 30;
$__payGap  = $footerPaymentStyle === 'bare' ? 16 : 9;
?>
.hc-foot-pay{display:grid;grid-template-columns:repeat(<?= $footerPaymentPerRow ?>,minmax(0,<?= $__payCell ?>px));gap:<?= $__payGap ?>px;justify-items:stretch}
.hc-foot-pay-brand{margin-top:20px}
.hc-foot-pay-item{display:flex;align-items:center;justify-content:center;box-sizing:border-box;
  transition:transform .18s ease,box-shadow .18s ease,color .18s ease}
.hc-foot-pay-item img{display:block;max-width:100%;width:auto;height:auto;object-fit:contain;max-height:<?= $footerPaymentSize + 2 ?>px}
.hc-foot-pay-glyph i{display:block;font-size:<?= $footerPaymentSize ?>px;line-height:1}
<?php if ($footerPaymentStyle === 'plate'): ?>
.hc-foot-pay-item{height:<?= $footerPaymentSize + 18 ?>px;padding:5px 7px;border-radius:9px;background:<?= $footerPayPlateBg !== '' ? hc_esc($footerPayPlateBg) : '#fff' ?>;
  border:1px solid rgba(15,23,42,.08);box-shadow:0 1px 2px rgba(15,23,42,.06)}
.hc-foot-pay-glyph{color:<?= $footerPayPlateInk !== '' ? hc_esc($footerPayPlateInk) : '#1e293b' ?>}
<?php if ($footerPaymentHover): ?>
.hc-foot-pay-item:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(15,23,42,.18)<?= $footerPayHoverBg !== '' ? ';background:' . hc_esc($footerPayHoverBg) : '' ?>}
<?php if ($footerPayHoverInk !== ''): ?>
.hc-foot-pay-glyph:hover{color:<?= hc_esc($footerPayHoverInk) ?>}
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
/* Bare: no plate at all — the mark rides the footer's own text colour. */
.hc-foot-pay-item{height:auto;padding:0;border:0;background:none;box-shadow:none;border-radius:0;justify-content:flex-start}
.hc-foot-pay-glyph{color:var(--text-secondary)}
<?php if ($footerPaymentHover): ?>
.hc-foot-pay-item:hover{transform:translateY(-2px)}
.hc-foot-pay-glyph:hover{color:var(--text-primary)}
<?php endif; ?>
<?php endif; ?>
<?php if ($footerPaymentCard): ?>
/* ── ONE CARD AROUND THE WHOLE STRIP ─────────────────────────────────────────
   The surface is mixed from the footer's own ink rather than hard-coded white
   overlays, so the same rule reads correctly on a dark footer and a light one —
   a fixed rgba(255,255,255,…) panel disappears the moment the footer is pale.
   It hugs its contents: a card stretched to the column width would frame a lot
   of empty space next to four badges. */
.hc-foot-pay.hc-foot-pay-card{
  width:max-content;max-width:100%;box-sizing:border-box;
  padding:15px 16px;border-radius:18px;justify-items:center;
  background:linear-gradient(180deg,
    color-mix(in srgb,var(--text-primary) 7%,transparent),
    color-mix(in srgb,var(--text-primary) 2.5%,transparent));
  border:1px solid color-mix(in srgb,var(--text-primary) 12%,transparent);
  box-shadow:inset 0 1px 0 color-mix(in srgb,var(--text-primary) 9%,transparent),
             0 12px 30px rgba(2,6,23,.16);
  backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
  transition:transform .24s cubic-bezier(.22,1,.36,1),box-shadow .24s ease,border-color .24s ease;
}
<?php if ($footerPaymentCardHover): ?>
/* The card lifts as one object. The per-badge hover keeps its own switch, so a
   strip can lift the card, the marks, both, or neither. */
.hc-foot-pay.hc-foot-pay-card-hov:hover{
  transform:translateY(-3px);
  border-color:color-mix(in srgb,var(--text-primary) 20%,transparent);
  box-shadow:inset 0 1px 0 color-mix(in srgb,var(--text-primary) 13%,transparent),
             0 20px 44px rgba(2,6,23,.26);
}
@media(prefers-reduced-motion:reduce){
  .hc-foot-pay.hc-foot-pay-card-hov{transition:none}
  .hc-foot-pay.hc-foot-pay-card-hov:hover{transform:none}
}
<?php endif; ?>
<?php endif; ?>
<?php if ($footerPaymentMobile === 'bottom' && $footerPaymentPosition === 'brand'): ?>
/* PHASE_HC_FOOTER_BRAND — on a phone the badges are stranded under the brand text
   with the whole link list below them. display:contents dissolves the brand box so
   its children become grid items of .hc-foot-top alongside the columns, which is
   what lets `order` drop the payment strip BELOW the links. No DOM move, so nothing
   to undo when the viewport widens. */
@media(max-width:640px){
  #hc-foot .hc-foot-top.hc-foot-has-brand{display:grid;grid-template-columns:1fr;gap:0}
  #hc-foot .hc-foot-top.hc-foot-has-brand>.hc-foot-brand{display:contents}
  #hc-foot .hc-foot-brand-logo,#hc-foot .hc-foot-brand-name{order:1}
  #hc-foot .hc-foot-brand-text{order:2}
  #hc-foot .hc-foot-brand-contact{order:3;margin:16px 0 0;max-width:none}
  #hc-foot .hc-foot-social-brand{order:4;margin:22px 0}
  #hc-foot .hc-foot-top.hc-foot-has-brand>.hc-foot-cols{order:5;margin-top:22px}
  /* The strip becomes its own surface: a hairline glass panel with an inner top
     highlight, rather than a flat box drawn around the icons. */
  #hc-foot .hc-foot-pay{order:6;margin:26px 0 0;padding:15px 13px;border-radius:18px;
    grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 10px;justify-items:center;max-width:none;
    background:linear-gradient(180deg,rgba(255,255,255,.075),rgba(255,255,255,.025));
    border:1px solid rgba(255,255,255,.11);
    box-shadow:inset 0 1px 0 rgba(255,255,255,.13),0 14px 34px rgba(2,6,23,.30);
    backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
  #hc-foot .hc-foot-pay-note{order:7;margin:12px 0 0;text-align:center;max-width:none;opacity:.85}
}
<?php else: ?>
@media(max-width:640px){.hc-foot-pay{justify-self:center}}
<?php endif; ?>
.hc-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.hc-foot-pay-note{font-size:11.5px;line-height:1.5;color:var(--text-muted);margin:10px 0 0;max-width:38ch}
.hc-foot-social{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.hc-foot-social-brand{margin-top:16px}
.hc-foot-social-copy{margin-left:auto}
.hc-foot-social-link{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;
  border-radius:10px;color:var(--text-muted);text-decoration:none;
  background:color-mix(in srgb,var(--text-muted,#94a3b8) 12%,transparent);
  border:1px solid color-mix(in srgb,var(--text-muted,#94a3b8) 18%,transparent);
  transition:color .16s ease,background .16s ease,transform .16s ease,border-color .16s ease}
.hc-foot-social-link:hover{color:var(--brand-ink,var(--brand));transform:translateY(-2px);
  background:rgba(var(--br),.14);border-color:rgba(var(--br),.34)}
.hc-foot-social-link svg{display:block}
@media(max-width:860px){.hc-foot-top.hc-foot-has-brand,.hc-foot-top.hc-foot-has-brand.hc-foot-brand-right{grid-template-columns:1fr;gap:28px}
  .hc-foot-top.hc-foot-has-brand.hc-foot-brand-right>.hc-foot-brand{order:0}}
/* The brand-layout column rule above carries three classes, so it outranks the
   plain .hc-foot-cols mobile rules — they have to be restated here or the grid
   stays multi-column on a phone. */
@media(max-width:640px){.hc-foot-top.hc-foot-has-brand .hc-foot-cols{grid-template-columns:repeat(2,1fr);text-align:center}}
@media(max-width:400px){.hc-foot-top.hc-foot-has-brand .hc-foot-cols{grid-template-columns:1fr}}

/* The accordion button is a plain heading everywhere except the phone. */
#hc-foot button.hc-foot-col-title{display:block;width:100%;text-align:inherit;background:none;border:0;padding:0;font:inherit;
  color:var(--text-primary);font-size:<?= $footerColTitleSize > 0 ? $footerColTitleSize . 'px' : '14px' ?>;font-weight:800;letter-spacing:.04em;cursor:default}
#hc-foot button.hc-foot-col-title .hc-foot-acc-ico{display:none}
<?php if ($footerMobileAccordion): ?>
@media(max-width:640px){
  /* PHASE_HC_FOOTER_BRAND — phone accordion. A footer this long is unusable as a
     stack of 38 links, so each column collapses behind its own heading. */
  /* The ≤640px rules above centre the columns for a plain grid; an accordion is a
     LIST and has to read left-aligned, headings and links alike. */
  #hc-foot .hc-foot-cols,
  #hc-foot .hc-foot-top.hc-foot-has-brand .hc-foot-cols{display:grid;grid-template-columns:repeat(<?= $footerMobileCols ?>,minmax(0,1fr));gap:0 18px;text-align:left}
  #hc-foot .hc-foot-col,#hc-foot .hc-foot-col-links,#hc-foot .hc-foot-col-links li,
  #hc-foot .hc-foot-col-links a,#hc-foot button.hc-foot-col-title{text-align:left}
  /* The links list is a GRID, and the ≤640px base CSS centres it with
     `justify-items:center`. text-align cannot undo that — the items themselves are
     being centred in their tracks, so this is the rule that actually matters. */
  #hc-foot .hc-foot-col-links{justify-items:start}
  #hc-foot .hc-foot-col-links li{width:100%}
  /* The rule sits under the HEADING, so it reads as a divider for that row whether
     the column is open or shut. */
  #hc-foot button.hc-foot-col-title{display:flex;align-items:center;justify-content:space-between;gap:12px;
    width:100%;margin:0;padding:16px 2px;cursor:pointer;text-align:left;
    border-bottom:1px solid color-mix(in srgb,var(--text-muted,#94a3b8) 22%,transparent)}
  #hc-foot button.hc-foot-col-title:after{display:none}
  /* A tapped heading was keeping the UA focus ring, which drew a white box round
     the whole row. Keyboard focus still gets a visible ring. */
  #hc-foot button.hc-foot-col-title:focus{outline:none}
  #hc-foot button.hc-foot-col-title:focus-visible{outline:2px solid var(--brand);outline-offset:-2px;border-radius:6px}
  /* + turning into − , drawn from two rules so there is no icon font to load. */
  #hc-foot button.hc-foot-col-title .hc-foot-acc-ico{display:block;position:relative;width:15px;height:15px;flex:0 0 15px;opacity:.85}
  #hc-foot button.hc-foot-col-title .hc-foot-acc-ico:before,
  #hc-foot button.hc-foot-col-title .hc-foot-acc-ico:after{content:'';position:absolute;background:currentColor;border-radius:2px;transition:transform .22s ease,opacity .22s ease}
  #hc-foot button.hc-foot-col-title .hc-foot-acc-ico:before{left:0;top:6.5px;width:15px;height:2px}
  #hc-foot button.hc-foot-col-title .hc-foot-acc-ico:after{top:0;left:6.5px;width:2px;height:15px}
  #hc-foot button.hc-foot-col-title[aria-expanded="true"] .hc-foot-acc-ico:after{transform:rotate(90deg);opacity:0}
  /* Collapsed by default. max-height rather than the grid 0fr/1fr trick: that only
     sizes the FIRST row, so a column with eight links would spill open. 640px clears
     the tallest column here (8 links at ~38px) without a visible easing lag. */
  #hc-foot .hc-foot-col .hc-foot-col-links{max-height:0;overflow:hidden;gap:0;padding:0;
    transition:max-height .28s ease,padding .28s ease}
  #hc-foot .hc-foot-col.is-open .hc-foot-col-links{max-height:640px;padding:12px 0 16px}
  #hc-foot .hc-foot-col .hc-foot-col-links a{display:block;padding:9px 2px}
  #hc-foot .hc-foot-col{border-bottom:0}
}
<?php endif; ?>
@media(max-width:640px){.hc-foot-brand{text-align:center}.hc-foot-brand-logo{margin-left:auto;margin-right:auto}
  .hc-foot-brand-text{max-width:none}.hc-foot-social{justify-content:center}.hc-foot-social-copy{margin-left:0}}
@media(max-width:640px){.hc-foot-rich .hc-foot-bottom{flex-direction:column;justify-content:center;text-align:center}.hc-foot-cols{grid-template-columns:repeat(2,1fr);gap:22px;text-align:center}.hc-foot-col-links{justify-items:center}.hc-foot-legal{justify-content:center}}
@media(max-width:400px){.hc-foot-cols{grid-template-columns:1fr}}

/* ── Back to top ────────────────────────────────────────────────────────────── */
<?php if (!$_embed): ?>
#hc-top{position:fixed;bottom:28px;right:28px;
  width:42px;height:42px;border-radius:13px;
  background:var(--brand);color:#fff;border:none;cursor:pointer;
  display:flex;align-items:center;justify-content:center;
  box-shadow:var(--shadow-brand);
  opacity:0;transform:translateY(10px);transition:all .25s var(--ease);
  pointer-events:none;z-index:50}
#hc-top.show{opacity:1;transform:translateY(0);pointer-events:auto}
#hc-top:hover{filter:brightness(1.12);transform:translateY(-2px)}
/* PHASE_HC_MORE_CONFIG — back-to-top on the left instead of the right. */
#hc-top.hc-totop-left{left:28px;right:auto}
<?php endif; ?>

/* ── Minimal layout — clean typography, no hero ─────────────────────────────── */
.layout-minimal{--body-bg:#fff;--card-bg:#fff;--card-border:var(--g200)}
.layout-minimal .hc-hero{display:none!important}
.layout-minimal .hc-main{padding-top:56px}
.layout-minimal .hc-art-card{border:none;border-radius:0;padding:0;box-shadow:none;
  border-bottom:1px solid var(--g200);padding-bottom:40px}
.layout-minimal .hc-arow{border:none;border-bottom:1px solid var(--g200);
  border-radius:0;padding:14px 0;background:transparent}
.layout-minimal .hc-arow::before{display:none}
.layout-minimal .hc-arow:hover{transform:none;box-shadow:none;border-color:var(--g200)}
.layout-minimal .hc-arow:hover .hc-arow-title{color:var(--brand-ink,var(--brand))}
.layout-minimal .hc-cat{border:none;border-radius:0;padding:12px 0;background:transparent;
  border-bottom:1px solid var(--g100)}
.layout-minimal .hc-cat::before{display:none}
.layout-minimal .hc-cat-ico{display:none}
.layout-minimal .hc-cat:hover{transform:none;box-shadow:none}
.layout-minimal .hc-cat-name{font-size:15px;color:var(--brand-ink,var(--brand))}
.layout-minimal .hc-cat-foot{display:none}
.layout-minimal .hc-cats{grid-template-columns:1fr 1fr 1fr;gap:0}
.layout-minimal .hc-pop-item{border:none;border-bottom:1px solid var(--g100);
  border-radius:0;background:transparent;padding:12px 0}
.layout-minimal .hc-pop-item:hover{transform:none;box-shadow:none}
.layout-minimal .hc-side-card{border:1px solid var(--g200);border-radius:10px}

/* ── Aurora layout — light gradient ─────────────────────────────────────────── */
.layout-aurora{--body-bg:#f5f7ff;--card-bg:#fff;--card-border:var(--g200);
  --hero-text:var(--g900);--hero-sub:var(--g600)}
.layout-aurora .hc-hero{
  background:linear-gradient(135deg,#f0f4ff 0%,#e8f0ff 50%,#f5f0ff 100%);
  border-bottom:1px solid var(--g200)}
.layout-aurora .hc-hero::before{display:none}
.layout-aurora .hc-hero::after{display:none}
.layout-aurora .hc-orb-1{background:radial-gradient(circle,rgba(<?= $brandRGB ?>,.12) 0%,transparent 68%)}
.layout-aurora .hc-orb-2{background:radial-gradient(circle,rgba(<?= $brandRGB ?>,.08) 0%,transparent 72%)}
.layout-aurora .hc-hero-pill{background:rgba(<?= $brandRGB ?>,.1);border-color:rgba(<?= $brandRGB ?>,.2);color:var(--brand-ink,var(--brand))}
.layout-aurora .hc-hero-dot{box-shadow:none}
.layout-aurora .hc-hero-stats{color:var(--g500)}
.layout-aurora .hc-hero-stats strong{color:var(--g700)}
.layout-aurora .hc-stat-sep{background:var(--g300)}
.layout-aurora .hc-srch-glass{background:#fff;border-color:var(--g300);
  box-shadow:var(--shadow-md);backdrop-filter:none;-webkit-backdrop-filter:none}
.layout-aurora .hc-srch-glass:focus-within{border-color:var(--brand);
  box-shadow:var(--shadow-md),0 0 0 3px var(--brand-10)}
.layout-aurora .hc-srch-ico{color:var(--g400)}
.layout-aurora .hc-srch-glass input{color:var(--g900)}
.layout-aurora .hc-srch-glass input::placeholder{color:var(--g400)}
.layout-aurora .hc-hero-wave path{fill:#f5f7ff}
.layout-aurora .hc-suggest{background:#fff}
.layout-aurora .hc-hero .hc-suggest{background:#fff;border-color:var(--g200)}
.layout-aurora .hc-hero .hc-suggest-item{color:var(--g700)}
.layout-aurora .hc-hero .hc-suggest-item:hover,.layout-aurora .hc-hero .hc-suggest-item.hc-act{
  background:var(--brand-5);color:var(--brand-ink,var(--brand))}
.layout-aurora .hc-hero .hc-suggest-item svg{color:var(--g400)}
.layout-aurora .hc-cat{border-left:3px solid var(--brand)}
.layout-aurora .hc-cat::before{display:none}

/* ── Obsidian layout — full dark ─────────────────────────────────────────────── */
.layout-obsidian{--body-bg:#0d0d0d;--card-bg:#1a1a1a;--card-border:#2a2a2a;
  --text-primary:#f1f5f9;--text-secondary:#94a3b8;--text-muted:#475569;
  --hero-text:#f1f5f9;--hero-sub:rgba(255,255,255,.4)}
.layout-obsidian body,.layout-obsidian .hc-main,.layout-obsidian .hc-foot{
  background:var(--body-bg);color:var(--text-primary)}
.layout-obsidian .hc-hero{
  background:linear-gradient(145deg,#111 0%,#0d0d0d 50%,rgba(<?= $brandRGB ?>,.18) 100%)}
</style>
<?php /* PHASE0.5_2026-08-08 — 31,003 bytes of static rules lifted to /opsiq/assets/hc-sheet-4.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-4.css')) ?>">
<style>

<?php if ($_navBgMode === 'solid' && $_navBgColor !== ''): ?>.hc-hdr{background:<?= hc_esc($_navBgColor) ?>!important;border-color:color-mix(in srgb,<?= hc_esc($_navBgColor) ?> 78%,#ffffff)!important}.hc-nav-floating{background:<?= hc_esc($_navBgColor) ?>!important}<?php elseif ($_navBgMode === 'gradient'): ?>.hc-hdr{background:<?= hc_esc($_navGradientBg) ?>!important;border-color:rgba(255,255,255,.24)!important}.hc-nav-floating{background:<?= hc_esc($_navGradientBg) ?>!important}<?php endif; ?>
<?php if ($_navTextColor !== ''): ?>.hc-hdr,.hc-hdr .hc-logo,.hc-hdr .hc-nav-links>a,.hc-hdr .hc-nav-drop>button,.hc-hdr .hc-op-logo-text,.hc-hdr .hc-hdr-home{color:<?= hc_esc($_navTextColor) ?>!important}.hc-hdr .hc-nav-menu a{color:#0f172a!important}<?php endif; ?>
<?php if ($_navHoverColor !== ''): ?>.hc-nav-links>a:hover,.hc-nav-drop>button:hover,.hc-hdr-home:hover{background:color-mix(in srgb,<?= hc_esc($_navHoverColor) ?> 18%,transparent)!important}.hc-nav-menu a:hover{background:color-mix(in srgb,<?= hc_esc($_navHoverColor) ?> 16%,transparent)!important}.hc-nav-cta.hc-cta-primary{background:linear-gradient(135deg,<?= hc_esc($_navHoverColor) ?>,color-mix(in srgb,<?= hc_esc($_navHoverColor) ?> 66%,#06b6d4))!important}<?php endif; ?>
<?php /* PHASE_HC_NAV_BUILD3 — nav link typography (font size + family). */ ?>
<?php if ($_navFontSize > 0): ?>.hc-hdr .hc-nav-links>a,.hc-hdr .hc-nav-drop>button{font-size:<?= (float)$_navFontSize ?>px}<?php endif; ?>
<?php if ($_navFont !== ''): ?>.hc-hdr .hc-nav-links>a,.hc-hdr .hc-nav-drop>button,.hc-hdr .hc-nav-cta,.hc-hdr .hc-nav-menu a{font-family:<?= hc_esc($_navFont) ?>}<?php endif; ?>

.hc-op-logo-word{font-weight:950;font-size:20px;line-height:1;color:inherit;letter-spacing:0}
</style>
<?php /* PHASE0.5_2026-08-08 — 92,657 bytes of static rules lifted to /opsiq/assets/hc-sheet-5.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-5.css')) ?>">
<style>


/* ── Embed mode ─────────────────────────────────────────────────────────────── */
<?php if ($_embed): ?>
body{background:transparent}
/* Chromium ignores ::-webkit-scrollbar once scrollbar-width is not auto, so the standard pair is Gecko-fenced. */
@supports (-moz-appearance:none){html,body{scrollbar-width:thin;scrollbar-color:transparent transparent}html:hover,body:hover{scrollbar-color:rgba(var(--br),.42) transparent}}
body::-webkit-scrollbar{width:3px;height:3px}
body::-webkit-scrollbar-track{background:transparent}
body::-webkit-scrollbar-thumb{border-radius:999px;background:transparent;box-shadow:none}body:hover::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--brand) 40%,transparent)}
body::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb,var(--brand) 56%,transparent)}
.hc-hdr{display:none!important}
.hc-hero{padding:32px 18px 44px}
.hc-hero h1{font-size:26px}
.hc-hero-pill{display:none}
.hc-main{padding:22px 18px 44px}
.hc-foot{margin-top:24px;padding:16px 18px}
/* ⚠ THE WIDGET HAS ITS OWN VIEW — FULL-PAGE CHROME DOES NOT BELONG IN IT.
   The panel already hides .hc-hdr, but the FOOTER was only padded, so a brought-your-own
   footer rendered whole inside a ~470px slide-out: mega columns, the CTA ribbon, the
   newsletter, the social row. It is also where the operator's absolute links live, which is
   how a click sent the panel cross-origin to their own help-center domain and the browser
   refused to frame it (owner, 2026-09-02). The built-in compact footer is kept — it is sized
   for this — and only the operator's page-scale footer is dropped. */
#hc-foot.hc-foot-custom,#px-footer .pfoot-custom{display:none!important}
/* ⚠ THE PANEL'S CONTENT WAS LAID OUT AT PAGE WIDTH (owner, 2026-09-02: "the article
   listing page is extending out, not in widget mode"). Measured live: .hc-main rendered
   1306px inside a 470px frame — 836px of overflow — on the CATEGORY view.
   hc-sheet-9 already carries `#hc-page.hc-widget .hc-main{width:auto;max-width:none}`,
   but the page-layout sheet pins the container with a DOUBLED id — `#hc-page#hc-page
   .hc-main{max-width:var(--hc-container)}` (2,0,1) — which outranks it (1,2,0). The widget
   rule was correct and simply never applied. Doubling the id here too, plus the widget
   class, makes it (2,1,1) so it wins on specificity rather than on which sheet loads last. */
/* ⚠ max-width:100%, NOT none. hc-sheet-9's widget rule says `max-width:none`, which does
   not narrow anything — it REMOVES the ceiling and lets the block grow to its max-content
   width (measured: 1306px in a 470px frame). Proven on the live panel by setting each
   candidate in turn: width:auto, flex:1 1 auto and align-self:stretch all left it at 1306;
   max-width:100% collapsed it to 470 immediately. The container is a COLUMN flex, so width
   is the CROSS axis — which is why none of the main-axis levers moved it. */
#hc-page#hc-page.hc-widget .hc-main,
#hc-page#hc-page.hc-widget .hc-kb-shell,
#hc-page#hc-page.hc-widget .hc-foot-inner{max-width:100%!important;width:auto!important}
/* ⚠ AND THE FLEXBOX HALF, WHICH IS WHAT ACTUALLY HELD IT OPEN. #hc-page is a FLEX
   container, so .hc-main is a flex ITEM — and a flex item defaults to `min-width:auto`,
   which refuses to shrink below its content's intrinsic width. Releasing max-width alone
   changed the computed value to `none` and the element still measured 1306px, because the
   floor was min-width, not max-width. min-width:0 is the only thing that lets a flex item
   narrower than its content. */
#hc-page#hc-page.hc-widget .hc-main,
#hc-page#hc-page.hc-widget .hc-page-shell,
#hc-page#hc-page.hc-widget .hc-page-content{min-width:0!important}
/* "Still need help?" sat flush against the last article card in the panel — the list
   supplies its own 8px row gap, and this card is a SIBLING of the list, so it inherited
   nothing (owner, 2026-09-02: "still need help is touching the cards"). Measured: 0px
   between them. */
#hc-page#hc-page.hc-widget .hc-help-card{margin-top:14px!important}
#hc-prog,#hc-top{display:none!important}
<?php endif; ?>

/* Sidebar category directory */
.hc-kb-shell{display:grid;grid-template-columns:minmax(240px,300px) minmax(0,1fr);gap:24px;align-items:start;margin-bottom:54px}
</style>
<?php /* PHASE0.5_2026-08-08 — 108,572 bytes of static rules lifted to /opsiq/assets/hc-sheet-6.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a style element
        compete purely on document order, so moving the tag would reorder the
        cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-6.css')) ?>">
<style>


/* Final typography overrides from Text & Labels settings */
<?php /* PHASE10K7_2026-08-11 — EVERY rule below carries the id TWICE. A single
         `#hc-page .hc-label-x` is (1 id, 1 class), which LOSES to the sidebar and
         theme pins that are (1 id, 2 classes) and equally !important — which is
         exactly why the operator's browse-label size saved 32px while the sidebar
         heading stayed at 11px. Two ids outrank all of them, and it is the same
         device the card FX layer already uses for the same reason.
         This note is a PHP comment, not a CSS one: everything inside <style>
         ships to every visitor on every page view. */ ?>
<?php if ($_txtFsHeroHeading !== ''): ?>#hc-page#hc-page .hc-hero h1{font-size:<?= hc_esc($_txtFsHeroHeading) ?>!important}<?php endif; ?>
<?php if ($_txtFsHeroSub !== ''): ?>#hc-page#hc-page .hc-hero-sub{font-size:<?= hc_esc($_txtFsHeroSub) ?>!important}<?php endif; ?>
<?php if ($_txtFsSearchPh !== ''): ?>#hc-page#hc-page .hc-search-form input[type=search],#hc-page#hc-page .hc-search-form input[type=search]::placeholder{font-size:<?= hc_esc($_txtFsSearchPh) ?>!important}<?php endif; ?>
<?php if ($_txtFsSearchBtn !== ''): ?>#hc-page#hc-page .hc-search-form button,#hc-page#hc-page .hc-srch-glass button{font-size:<?= hc_esc($_txtFsSearchBtn) ?>!important}<?php endif; ?>
<?php if ($_txtFsBrowseLabel !== ''): ?>#hc-page#hc-page .hc-label-browse{font-size:<?= hc_esc($_txtFsBrowseLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsPopularLabel !== ''): ?>#hc-page#hc-page .hc-label-popular{font-size:<?= hc_esc($_txtFsPopularLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsFeaturedLabel !== ''): ?>#hc-page#hc-page .hc-label-featured{font-size:<?= hc_esc($_txtFsFeaturedLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsNewsLabel !== ''): ?>#hc-page#hc-page .hc-news-title{font-size:<?= hc_esc($_txtFsNewsLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsQuickLabel !== ''): ?>#hc-page#hc-page .hc-label-quick{font-size:<?= hc_esc($_txtFsQuickLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsCatsLabel !== ''): ?>#hc-page#hc-page .hc-label-cats{font-size:<?= hc_esc($_txtFsCatsLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsFaqLabel !== ''): ?>#hc-page#hc-page .hc-label-faq{font-size:<?= hc_esc($_txtFsFaqLabel) ?>!important}<?php endif; ?>
<?php /* PHASE10K7 — the three labels that had no size control at all: the links
         band heading (the operator's "Popular right now"), both sidebar card
         titles, and the sidebar help card's title. */ ?>
<?php if ($_txtFsLinksLabel !== ''): ?>#hc-page#hc-page .hc-label-links{font-size:<?= hc_esc($_txtFsLinksLabel) ?>!important}<?php endif; ?>
<?php if ($_txtFsSideTitle !== ''): ?>#hc-page#hc-page .hc-side-title{font-size:<?= hc_esc($_txtFsSideTitle) ?>!important}<?php endif; ?>
<?php if ($_txtFsHelpCardTitle !== ''): ?>#hc-page#hc-page .hc-help-card-title{font-size:<?= hc_esc($_txtFsHelpCardTitle) ?>!important}<?php endif; ?>
<?php if ($_txtFsNoResults !== ''): ?>#hc-page#hc-page .hc-no-results-msg{font-size:<?= hc_esc($_txtFsNoResults) ?>!important}<?php endif; ?>
<?php if ($_txtFsFeedbackQ !== ''): ?>#hc-page#hc-page .hc-fb-q{font-size:<?= hc_esc($_txtFsFeedbackQ) ?>!important}<?php endif; ?>
<?php if ($_txtFsContactPrompt !== ''): ?>#hc-page#hc-page .hc-contact-prompt,#hc-page#hc-page .hc-contact-prompt a,#hc-page#hc-page .hc-contact-prompt span{font-size:<?= hc_esc($_txtFsContactPrompt) ?>!important}<?php endif; ?>

<?php if ($_textColorOverrideEnabled && $_textColorMode === 'normal' && $_textColorNormal !== ''): ?>
#hc-page{--text-primary:<?= hc_esc($_textColorNormal) ?>!important;--text-secondary:color-mix(in srgb,<?= hc_esc($_textColorNormal) ?> 78%,#94a3b8)!important;--text-muted:color-mix(in srgb,<?= hc_esc($_textColorNormal) ?> 58%,#94a3b8)!important}
<?php elseif ($_textColorOverrideEnabled && $_textColorMode === 'gradient'): ?>
#hc-page .hc-hero h1,#hc-page .hc-pop-title,#hc-page .hc-arow-title,#hc-page .hc-art-title,#hc-page .hc-kb-cat-name,#hc-page .hc-sec-label,#hc-page .hc-label-browse,#hc-page .hc-label-popular,#hc-page .hc-label-featured,#hc-page .hc-label-cats{background:<?= hc_esc($_textGradientCss) ?>!important;-webkit-background-clip:text!important;background-clip:text!important;color:transparent!important;-webkit-text-fill-color:transparent!important}
<?php endif; ?>
/* PHASE_HC_FOOTER_BRAND — column heads read as HEADINGS, not another link. The base
 * .hc-foot-col-title is dimmer than the links beneath it, which inverts the hierarchy;
 * these lift it and add the chosen treatment. */
#hc-foot .hc-foot-col-title{color:var(--text-primary);font-size:<?= $footerColTitleSize > 0 ? $footerColTitleSize . 'px' : '14px' ?>;font-weight:800;letter-spacing:.04em;margin-bottom:18px}
<?php if ($footerLinkSize > 0): ?>
#hc-foot .hc-foot-col-links a{font-size:<?= $footerLinkSize ?>px}
<?php endif; ?>
<?php if ($footerLinkGap > 0): ?>
#hc-foot .hc-foot-col-links{gap:<?= $footerLinkGap ?>px}
<?php endif; ?>
<?php if ($footerTopGap > 0): ?>
/* Body-coloured separation: margin sits OUTSIDE the footer's own background, which
   is the difference between "more air" and "a taller dark block".
   !important is REQUIRED here — an earlier `.hc-foot{margin-top:0!important}` beats
   a plain #id rule, because !important outranks specificity outright. */
#hc-foot{margin-top:<?= $footerTopGap ?>px!important}
<?php endif; ?>
<?php /* HC_CUSTOM_FOOTER_NO_CHROME_DIVIDER_2026-08-18 — a bring-your-own footer
        (footer_custom_html → .hc-foot-custom) carries its own design; HC must not
        stamp its chrome divider on top of it. hc-sheet-5 sets
        `.hc-foot,#hc-page ~ .hc-foot{border-top:1px solid rgba(148,163,184,.22)!important}`
        (1,1,0 + !important), so only an ID+class rule with !important beats it.
        Scoped to .hc-foot-custom, so the BUILT-IN HC footer keeps its divider. */ ?>
#hc-foot.hc-foot-custom{border-top:0!important}
/* The copyright row: text left, legal links + social grouped right. */
#hc-foot .hc-foot-bottom{justify-content:space-between}
#hc-foot .hc-foot-bottom-end{display:flex;align-items:center;gap:10px 26px;flex-wrap:wrap;margin-left:auto}
<?php if ($footerLegalSize > 0): ?>
#hc-foot .hc-foot-legal a{font-size:<?= $footerLegalSize ?>px}
<?php endif; ?>
<?php if ($footerLockOverscroll && empty($_widget)): ?>
/* Dragging past the footer was rubber-banding to the white canvas underneath.
   overscroll-behavior stops the drag itself; the canvas colour is set to the
   footer's own surface as the fallback for engines that ignore it, so the worst
   case is a band of footer colour rather than a flash of white.
   NOT IN WIDGET MODE: the panel is its own iframe with its own scroll container
   and no footer at all, so painting <html> the footer's colour just turned the
   panel's canvas dark — and the widget's translucent cards then read as grey. */
html{overscroll-behavior-y:none;background:<?= $footerCopyBgMode === 'color' && $footerCopyBgColor !== '' ? hc_esc($footerCopyBgColor) : ($_footerBgMode !== 'none' && $_footerColor !== '' ? hc_esc($_footerColor) : 'var(--body-bg,#fff)') ?>}
body{overscroll-behavior-y:none}
<?php endif; ?>
#hc-foot .hc-foot-social-copy{margin-left:0}
/* Social glyph size is independent of the payment badges. */
#hc-foot .hc-foot-social-link svg{width:<?= $footerSocialSize ?>px;height:<?= $footerSocialSize ?>px}
<?php if ($footerSocialStyle === 'plain'): ?>
/* Plain glyphs — no chip behind them. */
#hc-foot .hc-foot-social-link{width:auto;height:auto;padding:2px;border:0;background:none;border-radius:0}
#hc-foot .hc-foot-social-link:hover{background:none;border:0}
#hc-foot .hc-foot-social{gap:<?= max(10, (int)round($footerSocialSize * 0.9)) ?>px}
<?php else: ?>
#hc-foot .hc-foot-social-link{width:<?= $footerSocialSize + 17 ?>px;height:<?= $footerSocialSize + 17 ?>px}
<?php endif; ?>
<?php if ($footerColTitleStyle === 'underline'): ?>
#hc-foot .hc-foot-col-title{padding-bottom:9px;border-bottom:1px solid color-mix(in srgb,var(--text-muted,#94a3b8) 26%,transparent)}
<?php elseif ($footerColTitleStyle === 'bar'): ?>
/* A 3px solid stub read as a stray blue dash. Thinner, wider, and faded out at the
   end so it sits under the word as a rule rather than a marker. */
#hc-foot .hc-foot-col-title{position:relative;padding-bottom:13px}
#hc-foot .hc-foot-col-title:after{content:'';position:absolute;left:0;bottom:0;width:44px;height:2px;border-radius:2px;
  background:linear-gradient(90deg,var(--brand),color-mix(in srgb,var(--brand) 10%,transparent))}
<?php elseif ($footerColTitleStyle === 'boxed'): ?>
#hc-foot .hc-foot-col-title{display:inline-block;padding:5px 10px;border-radius:8px;letter-spacing:.07em;
  background:color-mix(in srgb,var(--text-muted,#94a3b8) 14%,transparent)}
<?php endif; ?>
<?php
/* Air between the link columns and the copyright row. The default was tight enough
 * that the two bands read as one block. */
$__fsPad = ['compact' => ['34px','18px','16px'], 'default' => ['56px','40px','26px'], 'roomy' => ['78px','58px','34px']][$footerSpacing];
?>
/* A brand block plus five link columns needs more than the 1160px the plain footer
   was built for, or the columns crush. */
#hc-foot.hc-foot-rich .hc-foot-band-inner{max-width:1320px}
#hc-foot.hc-foot-rich{padding-top:<?= $__fsPad[0] ?>}
#hc-foot .hc-foot-links-band .hc-foot-band-inner{padding-bottom:<?= $__fsPad[1] ?>}
#hc-foot .hc-foot-copy-band .hc-foot-band-inner{padding-top:<?= $__fsPad[2] ?>;padding-bottom:<?= $__fsPad[2] ?>}
#hc-foot .hc-foot-cols{margin-bottom:0;padding-bottom:0;border-bottom:0}
#hc-foot .hc-foot-copy-band{border-top:1px solid color-mix(in srgb,var(--text-muted,#94a3b8) 20%,transparent)}
<?php if ($footerCopyBgMode === 'color'): ?>
/* Its own surface, so the copyright row can sit a shade apart from the links band. */
#hc-foot .hc-foot-copy-band{background:<?= hc_esc($footerCopyBgColor) ?>}
<?php endif; ?>
<?php if ($__footDark): ?>
/* PHASE_HC_FOOTER_BRAND — the operator picked a dark footer background. Re-point the
 * footer's own text vars so the copyright, column titles, links and social glyphs
 * stay legible on it. Scoped to the footer, and NOT !important, so the Design Studio
 * per-element colours (emitted after this, with !important) still override it. */
.hc-foot{--text-primary:#f8fafc;--text-secondary:#cbd5e1;--text-muted:#94a3b8}
.hc-foot .hc-foot-col-title{color:#f8fafc}
.hc-foot .hc-foot-col-links a,.hc-foot .hc-foot-legal a,.hc-foot .hc-foot-text{color:#cbd5e1}
.hc-foot .hc-foot-brand-name{color:#f8fafc}
.hc-foot .hc-foot-brand-text{color:#94a3b8}
.hc-foot .hc-foot-col-links a:hover,.hc-foot .hc-foot-legal a:hover{color:#fff}
.hc-foot .hc-foot-social-link{color:#cbd5e1;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.14)}
.hc-foot .hc-foot-social-link:hover{color:#fff;background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.28)}
.hc-foot .hc-foot-cols{border-bottom-color:rgba(255,255,255,.12)}
.hc-foot .hc-foot-copy-band{border-top-color:rgba(255,255,255,.12)}
<?php endif; ?>
<?php if ($articleTitleSize > 0): $__ats = max(14, min(72, $articleTitleSize)); ?>
/* PHASE_HC_TYPOGRAPHY — custom article reading-page title size. Emitted LAST and
 * scoped to #hc-page.view-article so it out-ranks the layout defaults above
 * (equal specificity → later rule wins; those are !important too). */
#hc-page.view-article .hc-art-title,#hc-page.view-article .hc-subhero-title{font-size:<?= $__ats ?>px!important;line-height:1.15!important}
@media(max-width:560px){#hc-page.view-article .hc-art-title,#hc-page.view-article .hc-subhero-title{font-size:<?= max(16, (int)round($__ats*0.86)) ?>px!important}}
<?php endif; ?>


<?php /* PHASE2_CARDS_FIX_2026-08-07 — this used to sit beside the custom_css
 * emit, which turns out to live inside the PORTAL-CHROME region. A plain help
 * centre host never enters that branch, so the whole block never ran and
 * visual_depth appeared to do nothing: the page carried the depth-flat class
 * while the rule that reads it was never emitted. Proved with an inline probe —
 * even the CSS immediately before it was absent from the served page.
 *
 * It now runs inside the main stylesheet, which is always emitted, and writes
 * its rules there rather than opening a <style> of its own. */ ?>
<?php
/* PHASE2_CARDS_2026-08-06 — PER-SURFACE CARD OVERRIDES.
 *
 * The five global card settings set --card-* tokens on #hc-page. These emit the
 * same tokens on ONE card type, so it can step out of the global choice without
 * custom CSS. Because the consumers read var(--card-*), a token set on the card
 * itself simply resolves closer than the one on #hc-page — no !important, no
 * specificity contest, and the global keeps applying to every other card.
 *
 * An empty setting emits NOTHING. That is what keeps "inherit" honest: the card
 * goes on resolving from #hc-page, so changing the global still reaches it.
 * Writing the resolved global value here instead would freeze the card at
 * whatever the global was the day it was saved.
 *
 * Emitted before the operator's custom CSS so their code still has the last word.
 */
$__cardOvMaps = [
    'radius'  => ['sharp' => '4px', 'soft' => '14px', 'sculpted' => '22px'],
    'density' => [
        'compact'     => ['13px', '15px'],
        'comfortable' => ['17px', '20px'],
        'airy'        => ['25px', '28px'],
    ],
    'depth' => [
        'flat'    => ['none', '0 3px 10px rgba(15,23,42,.07)'],
        'deep'    => ['0 8px 28px rgba(15,23,42,.06)', '0 18px 44px rgba(15,23,42,.10)'],
        'extreme' => ['0 18px 48px rgba(15,23,42,.12)', '0 34px 76px rgba(15,23,42,.18)'],
    ],
    'edge' => [
        'lifted'   => ['1px',   'color-mix(in srgb, var(--card-border,#e5e7eb) 40%, transparent)'],
        'bordered' => ['1.5px', 'var(--card-border,#e5e7eb)'],
        'soft'     => ['1px',   'color-mix(in srgb, var(--card-border,#e5e7eb) 70%, transparent)'],
    ],
    'fill' => [
        'solid'   => 'var(--card-bg,#fff)',
        'pearl'   => 'color-mix(in srgb, var(--card-bg,#fff) 92%, var(--body-bg,#fff) 8%)',
        'smoke'   => 'color-mix(in srgb, var(--card-bg,#fff) 90%, var(--text-primary,#0f172a) 10%)',
        'crystal' => 'color-mix(in srgb, var(--card-bg,#fff) 82%, transparent)',
    ],
];
/* surface key => the selector its tokens land on. The tiles deliberately list
 * only the presentations that ARE cards; minimal/bare/plain stay unpainted. */
$__cardOvSel = [
    'arow' => '#hc-page .hc-arow',
    'side' => '#hc-page .hc-side-card,#hc-page .hc-kb-sidebar',
    'tile' => '#hc-page .hc-cats-card .hc-home-cat,#hc-page .hc-cats-badge .hc-home-cat,'
            . '#hc-page .hc-cats-detail .hc-home-cat,#hc-page .hc-cats-list .hc-home-cat,'
            . '#hc-page .hc-cats-glow .hc-home-cat,#hc-page .hc-cats-tint-theme .hc-home-cat',
];
$__cardOvCss = '';
foreach ($__cardOvSel as $__sfc => $__sel) {
    $__decl = '';
    foreach (['radius', 'density', 'depth', 'edge', 'fill'] as $__prop) {
        $__v = trim((string)($_settings['card_ov_' . $__sfc . '_' . $__prop] ?? ''));
        if ($__v === '' || !isset($__cardOvMaps[$__prop][$__v])) continue;   // '' = inherit
        $__m = $__cardOvMaps[$__prop][$__v];
        switch ($__prop) {
            case 'radius':  $__decl .= '--card-radius:' . $__m . ';'; break;
            case 'density': $__decl .= '--card-pad-y:' . $__m[0] . ';--card-pad-x:' . $__m[1] . ';'; break;
            case 'depth':   $__decl .= '--card-shadow:' . $__m[0] . ';--card-shadow-hover:' . $__m[1] . ';'; break;
            case 'edge':    $__decl .= '--card-border-w:' . $__m[0] . ';--card-edge:' . $__m[1] . ';'; break;
            case 'fill':    $__decl .= '--card-fill:' . $__m . ';'; break;
        }
    }
    if ($__decl !== '') $__cardOvCss .= $__sel . '{' . $__decl . '}';
}

/* MAKING visual_depth REAL WITHOUT ERASING TWENTY THEMES.
 *
 * Each layout carries a signature shadow — stack's `0 8px 0 #dbeafe`, mosaic's
 * hard offset, editorial's paper lift — written as
 * `.layout-stack#hc-page .hc-side-card{box-shadow:…!important}`. That is (1,2,0)
 * and beats the (1,1,0) global consumer, which is why visual_depth measured as
 * having NO effect on all four card surfaces even though its tokens resolved
 * perfectly. The tokens were right; nothing was reading them.
 *
 * A blanket global override would win, and would also flatten the twenty
 * signatures into one shadow — repeating the mistake the band skins already
 * taught this programme once.
 *
 * So the override is applied ONLY when the operator has moved off the default.
 * At `deep` each theme keeps its own signature. At `flat` or `extreme` the
 * operator has made an explicit choice and it wins. Emitted late, so (1,2,0)
 * ties with the layout rules and takes it on document order. */
$__depthNow = strtolower(trim((string)($_settings['visual_depth'] ?? 'deep')));
if (in_array($__depthNow, ['flat', 'extreme'], true)) {
    $__depthSel = [];
    foreach (['.hc-arow', '.hc-side-card', '.hc-art-card', '.hc-pop-item', '.hc-kb-sidebar',
              '.hc-cats-card .hc-home-cat', '.hc-cats-badge .hc-home-cat',
              '.hc-cats-detail .hc-home-cat', '.hc-cats-list .hc-home-cat',
              '.hc-cats-glow .hc-home-cat', '.hc-cats-tint-theme .hc-home-cat'] as $__s) {
        $__depthSel[] = '#hc-page.depth-' . $__depthNow . ' ' . $__s;
    }
    $__cardOvCss .= implode(',', $__depthSel) . '{box-shadow:var(--card-shadow)!important}';
}

/* ══════════════════════════════════════════════════════════════════════════════
   PHASE_HC_SIDEBAR_CARD_OVERRIDE_2026-08-27 — THE SIDEBAR COLUMN, OPTIONALLY ITS OWN.

   Owner: *"or should we have for sidebar cards only? so that you can apply to sidebar
   differently?"* — yes, but as an OVERRIDE, never as a second card designer.

   All four cards in that column (`.hc-side-card` twice, `.hc-kb-sidebar`, `.hc-help-card`)
   now read the same four tokens, so re-styling the whole column is ONE rule that sets
   those tokens on the sidebar scope. Measured live before building it: a single
   `#hc-page .hc-sidebar{--card-radius:4px;--card-shadow:none;--card-pad-x:12px}` moved all
   four, on the article view, and the rail on the category view. Nothing per-card.

   TWO RULES THIS FOLLOWS, both learned the hard way today:

   1  'match' EMITS NOTHING. Not the same values re-stated — nothing at all. A workspace
      that never opens this group renders byte-identical CSS to before the feature
      existed, so the feature cannot regress anyone who does not use it.

   2  IT NEVER CARRIES ITS OWN COPY OF THE SCALE. The values come from the --sc-* tokens
      hc-sheet-1 declares once and the page's own family tiers read. A second copy of a
      scale attached to a different selector is exactly what put three radii and three
      shadows in this column in the first place (HcSidebarCardParityTest).

   The scope covers both places the rail lives: inside `.hc-sidebar` on article and
   category views, and as a direct child of `.hc-page-shell` on the browse views. */
$__cardOvCss .= hc_sidebar_card_override_css($_settings);

?>
<?= $__cardOvCss ?>
</style>
<?php
/* PHASE_HC_DESIGN_STUDIO — per-element colour overrides. Emitted AFTER the main
 * stylesheet so equal-specificity #hc-page rules win by order; each is skipped
 * when unset (theme default preserved). */
$__ds = [
    ['ds_page_bg',      'body,#hc-page',                                                                        false],
    ['ds_nav',          '#hc-hdr',                                                                              false],
    ['ds_nav_text',     '#hc-hdr .hc-nav-links>a,#hc-hdr .hc-nav-drop>button,#hc-hdr .hc-logo,#hc-hdr .hc-op-logo-word,#hc-hdr .hc-hdr-home', true],
    ['ds_footer',       '#hc-page ~ .hc-foot,.hc-foot',                                                         false],
    ['ds_footer_text',  '.hc-foot .hc-foot-col-title,.hc-foot .hc-foot-col-links a',                            true],
    ['ds_footer_bottom','.hc-foot .hc-foot-copy-band',                                                          false],
    ['ds_footer_bottom_text', '.hc-foot-copy-band .hc-foot-text,.hc-foot-copy-band .hc-foot-legal a',           true],
    ['ds_hero_bg',      '#hc-hero',                                                                             false],
    ['ds_hero_heading', '#hc-page .hc-hero h1',                                                                 true],
    ['ds_hero_sub',     '#hc-page .hc-hero-sub',                                                                true],
    ['ds_button',       '#hc-page .hc-search-form button,#hc-page .hc-srch-glass button,#hc-page .hc-nav-cta,#hc-page .hc-home-cat-count', false],
    ['ds_link',         '#hc-page .hc-body a',                                                                  true],
    ['ds_card',         '#hc-page .hc-cat,#hc-page .hc-pop-item,#hc-page .hc-arow,#hc-page .hc-art-card,#hc-page .hc-side-card', false],
    ['ds_card_text',    '#hc-page .hc-cat-name,#hc-page .hc-pop-title,#hc-page .hc-arow-title',                 true],
    ['ds_tile',         '#hc-page .hc-home-cat',                                                                false],
    /* PHASE_HC_ICON_REACH_2026-08-27 — THE CATEGORY ICON IS NOT ONE CLASS.
     *
     * Owner: *"icons styling like that don't use the icon or accent colours."*
     *
     * A category's icon is rendered by hc_cat_icon_html() in nine places, and these two
     * roles named four of them. The one the owner was looking at — `.hc-dir-ico`, the
     * icon inside a category card, which is every one of the twenty-seven directory
     * presentations — was not among them, and neither was the sidebar's icon or the
     * category chip's. Painting "Icon colour" left all three exactly as they were.
     *
     * boost 2 for the same reason the directory roles carry it: several presentations
     * reset `.hc-dir-ico` at `#hc-page#hc-page .hc-dir-v-x .hc-dir-ico{background:none
     * !important}`, which is (2,2,0), and a (1,1,0) important rule does not clear it.
     *
     * The five icon STYLE presets (solid / gradient / outline / glass / bare) are
     * deliberately NOT extended here. Those set a tile's size, radius and material, and
     * a presentation's icon shape is part of its design — ribbon draws a 52px gradient
     * tile, matrix a 1.4em bare glyph, krail a 1.15em one. Colour is the operator's;
     * shape is the presentation's. */
    ['ds_tile_icon',    '#hc-page .hc-home-cat-ico,#hc-page .hc-cat-ico,#hc-page .hc-cat-pg-ico,#hc-page .hc-subhero-icon,#hc-page .hc-dir-ico,#hc-page .hc-kb-cat-icon,#hc-page .hc-chip-ico', false, false, '', 2],
    /* PHASE_HC_ICON_STUDIO — colour the GLYPH itself (font-icon <i>, inline SVG
     * via currentColor, and an emoji/letter fallback). isText=true so a gradient
     * is background-clipped onto the glyph. Emitted after the icon-style presets,
     * so a colour chosen in the Studio always wins. */
    ['ds_icon',         '#hc-page .hc-home-cat-ico,#hc-page .hc-home-cat-ico>i,#hc-page .hc-cat-ico,#hc-page .hc-cat-ico>i,#hc-page .hc-cat-pg-ico,#hc-page .hc-cat-pg-ico>i,#hc-page .hc-kb-cat-icon,#hc-page .hc-kb-cat-icon>i,#hc-page .hc-subhero-icon,#hc-page .hc-subhero-icon>i,#hc-page .hc-dir-ico,#hc-page .hc-dir-ico>i,#hc-page .hc-chip-ico,#hc-page .hc-chip-ico>i', true, false, '', 2],
    /* PHASE_HC_SIDEBAR_COLOUR_2026-08-14 — the PANEL, not just the links in it.
     * `ds_sidebar` paints `.hc-kb-cat-link` — the links — so the rail itself had no
     * colour anywhere in the Studio (owner: *"the sidebar doesn't have where to change
     * the colour in colour studio"*). These two paint the surface and its own ink. */
    /* DOUBLED ID on purpose. hc-sheet-6 applies the card tokens to the rail through a
     * selector list whose most specific member is `#hc-page.view-article .hc-sidebar
     * .hc-kb-sidebar` — id+3 classes, !important — so a single-id rule here lost and the
     * colour silently did nothing. Two ids outrank it however many classes it carries,
     * which is the same reason HcFx doubles. */
    ['ds_sidebar_bg',   '#hc-page#hc-page .hc-kb-sidebar',                                                      false],
    ['ds_sidebar_text', '#hc-page#hc-page .hc-kb-sidebar .hc-side-title,#hc-page#hc-page .hc-kb-sidebar-head',  true],
    ['ds_sidebar',      '#hc-page .hc-kb-sidebar .hc-kb-cat-link',                                              false],
    ['ds_heading',      '#hc-page .hc-sec-label,#hc-page .hc-home-cat-name',                                    true],
    /* ── THE TWO HOME SECTIONS ────────────────────────────────────────────────
     * News and Quick links painted from the page tokens only, so neither was
     * addressable here: an operator could recolour every other surface and not
     * those. Each gets the parts an operator actually reaches for — the card,
     * its heading, the link text — and the quick-links dropdown gets its bar and
     * bar text, which are the loudest thing in that section.
     *
     * The tab rail's on-state is deliberately NOT here: it derives from
     * --hce-tab-on-accent, which follows the brand, and giving it a private
     * colour is how a section ends up looking like it belongs to a different
     * site. */
    /* PHASE0.5_2026-08-09 — the category card. Every rule below emits !important, so a
     * layout variant's own header/pill/chip colour is overridden by an explicit pick
     * without needing to out-specify nine separate .hc-dir-v-* rules.
     *
     * PHASE_HC_DS_REACH_2026-08-27 — that had stopped being true, and the 6th column is
     * what makes it true again. !important does not settle a contest between two
     * important declarations; specificity does, and the newer presentations reset their
     * card, header and count at `#hc-page#hc-page .hc-dir-v-x .y` — (2,2,0) — against
     * this table's (1,1,0). Eleven role/presentation pairs were measured saving a colour
     * and painting nothing. `boost:2` repeats the first id to (3,1,0), which clears the
     * strongest reset any presentation carries. See hc_ds_boost.
     *
     * 5th column: the inset this fill needs — see hc_ds_decl. `14px 20px` and
     * `2px 8px` are the values the presentations that DO inset these two already
     * use, so painting a well-behaved presentation lands on what it already had.
     *
     *                                                          isText lightOnly inset        boost */
    /* PHASE_HC_SUBHERO_COLOURS_2026-08-27 — the article/category page header.
     *
     * Owner: *"sub hero has no text colour for its title, icon, breadcrumbs and subtitle
     * in colour studio."* Quite right — the band could be given a photograph and a fill,
     * and then had no way to make the words on top of it readable. On a dark hero image
     * the title and trail rendered in the light theme's near-black and disappeared.
     *
     * boost 2 for the same reason the directory roles carry it: the per-layout subhero
     * skins colour these at `#hc-page.layout-x:is(.view-article,.view-category) .hcsh-y`,
     * which is (2,2,0) and loads after the emit block. An explicit pick has to outrank a
     * side effect of the page layout. `.hcsh-icon` gets both roles — the tile behind the
     * glyph and the glyph itself — because they are two decisions, not one. */
    ['ds_subhero_title', '#hc-page #hc-subhero .hcsh-title',      true,  false,    '',          2],
    ['ds_subhero_sub',   '#hc-page #hc-subhero .hcsh-sub',        true,  false,    '',          2],
    ['ds_subhero_crumb', '#hc-page #hc-subhero .hcsh-crumb,#hc-page #hc-subhero .hcsh-crumb a', true, false, '', 2],
    ['ds_subhero_icon',  '#hc-page #hc-subhero .hcsh-icon,#hc-page #hc-subhero .hcsh-icon>i',   true, false, '', 2],
    ['ds_subhero_icon_bg', '#hc-page #hc-subhero .hcsh-icon',     false, false,    '',          2],
    ['ds_dir_card',      '#hc-page .hc-dir-card',                false, false,    '',          2],
    ['ds_dir_head',      '#hc-page .hc-dir-head',                false, false,    'padding:14px 20px!important', 2],
    ['ds_dir_head_text', '#hc-page .hc-dir-title,#hc-page .hc-dir-title>span,#hc-page .hc-dir-num,#hc-page .hc-dir-toggle', true, false, '', 2],
    /* em, not px: `tree` renders this element as a 2.9em numeral, where a 2px inset
     * left the glyph outside its own fill (measured t-7 b-3.3). line-height with it,
     * because `line-height:1` is what makes a glyph overflow a padded box at all. */
    ['ds_dir_count',     '#hc-page .hc-dir-count',               false, false,    'padding:.28em .62em!important;line-height:1.45!important', 2],
    ['ds_dir_desc',      '#hc-page .hc-dir-desc',                true,  false,    '',          2],
    ['ds_dir_link',      '#hc-page .hc-dir-list a',              true,  false,    '',          2],
    ['ds_dir_kid',       '#hc-page .hc-dir-kids a',              false, false,    '',          2],
    ['ds_news_card',    '#hc-page .hc-news-card',                                                               false],
    ['ds_news_heading', '#hc-page .hc-news-title',                                                              true],
    ['ds_news_link',    '#hc-page .hc-news-link',                                                               true],
    ['ds_quick_bar',    '#hc-page .hce-topic-head',                                                             false],
    ['ds_quick_bar_text','#hc-page .hce-topic-head,#hc-page .hce-topic-title',                                  true],
    ['ds_quick_tab',    '#hc-page .hce-tab',                                                                    false],
    ['ds_quick_link',   '#hc-page .hce-topic-link',                                                             true],
    /* THE CTA BAND. It paints from --hce-cta-surface / -ink / -accent, so these
     * address the surface, the copy and both buttons. A template that ships its
     * own material (gradient, glass, full-bleed media) is still overridden by an
     * explicit choice here — that is the point of the control. */
    /* LIGHT MODE ONLY. A Colour Studio pick is one colour, not a pair, and this
     * role emits `!important` — so a light band chosen here also painted over the
     * dark-mode CTA and the band stayed light on a dark page. Everything else in
     * the studio tints something that is already theme-neutral; a full surface is
     * the one role where a single value cannot serve both. Restricting it to the
     * light theme lets `html[data-theme="dark"] .hce-cta` keep the dark band. */
    /* PHASE_HC_PALETTE_PER_THEME_2026-08-26 — the `html:not([data-theme="dark"])` that
     * used to be typed into each of these five selectors is now the 4th column,
     * `lightOnly`. Same output for a single value; the difference is that a value
     * carrying a `d` branch can now paint the dark band too, which a selector with the
     * scope baked in could never do. */
    ['ds_cta_bg',       '#hc-page .hce-cta',                                                                    false, true],
    /* The whole CTA group is light-mode-only for the same reason as the surface
     * above: a dark band with the light theme's near-black title on it is not
     * "dark mode", it is half of one. Scoping the group together keeps the band
     * and everything sitting on it in the same theme. */
    ['ds_cta_text',     '#hc-page .hce-cta-title,#hc-page .hce-cta-subhead,#hc-page .hce-cta-body',            true,  true],
    ['ds_cta_btn',      '#hc-page .hce-cta-btn',                                                                false, true],
    ['ds_cta_btn_text', '#hc-page .hce-cta-btn',                                                                true,  true],
    ['ds_cta_btn2',     '#hc-page .hce-cta-btn2',                                                               false, true],
];
$__dsCss = '';
foreach ($__ds as $__row) {
    /* The 4th column is optional — only the five CTA roles declare it — so a row that
     * omits it means "this role reaches both themes", which is what the other 35 do. */
    $__dsCss .= hc_ds_rule((string)($_settings[$__row[0]] ?? ''), $__row[1], (bool)$__row[2], (bool)($__row[3] ?? false), (string)($__row[4] ?? ''), (int)($__row[5] ?? 0));
}
if ($__dsCss !== ''): ?>
<style id="hc-design-studio">
<?= $__dsCss ?>
</style>
<?php endif; ?>
<?php /* PHASE3.4_2026-08-09 — the shared box strip.
        Emitted AFTER the Design Studio block so a spacing choice wins over anything the
        studio set, and only when something is actually configured — HcBox::css() returns
        '' for an untouched workspace, so no tag is emitted at all. */ ?>
<?php $__boxCss = class_exists('\\OpsIQ\\Kb\\HcBox') ? \OpsIQ\Kb\HcBox::css($_settings) : ''; ?>
<?php if ($__boxCss !== ''): ?>
<style id="hc-box">
<?= $__boxCss ?></style>
<?php endif; ?>
<?php /* PHASE10_2026-08-11 — the global page layout (HcPage): boxed/wide/full width,
        custom container width, per-device horizontal padding, custom reading width.
        AFTER the box strip so the page shell wins the document-order tie against a
        section-level choice, and emitted only when something is actually configured —
        an untouched workspace gets no tag at all. */ ?>
<?php $__pageCss = class_exists('\\OpsIQ\\Kb\\HcPage') ? \OpsIQ\Kb\HcPage::css($_settings) : ''; ?>
<?php if ($__pageCss !== ''): ?>
<style id="hc-pagelayout">
<?= $__pageCss ?></style>
<?php endif; ?>
<?php /* PHASE10B_2026-08-11 — custom card shadows + hover fx (HcFx). After the page
        shell; same emit-only-when-configured contract. */ ?>
<?php $__fxCss = class_exists('\\OpsIQ\\Kb\\HcFx') ? \OpsIQ\Kb\HcFx::css($_settings) : ''; ?>
<?php if ($__fxCss !== ''): ?>
<style id="hc-cardfx">
<?= $__fxCss ?></style>
<?php endif; ?>
<?php /* PHASE10C_2026-08-11 — the overflow link's presentation. Emitted only when a
        non-default variant is chosen, so an untouched workspace stays byte-identical. */ ?>
<?php /* PHASE_HC_MORELINK_2026-08-15 — the renderer emits `hc-dir-more-v-pill` /
        `-quiet` (help.php:4673) and NOTHING styled either class, so two of the four
        choices were pixel-identical to the default on both surfaces. The rules below key
        off the emitted classes rather than the bare `.hc-dir-more`, which also stops the
        CATEGORY setting restyling the SUBCATEGORY cards — the old block was unscoped. */ ?>
<?php $__mlv = strtolower(trim((string)($_settings['cat_more_variant'] ?? ''))); ?>
<?php if ($__mlv === 'pill'): ?>
<style id="hc-morelink">
#hc-page#hc-page .hc-dir-more{display:inline-flex;align-self:flex-start;border:1px solid var(--card-border,#e5e7eb);border-radius:999px;padding:7px 14px;background:var(--card-bg,#fff)}
</style>
<?php elseif ($__mlv === 'quiet'): ?>
<style id="hc-morelink">
#hc-page#hc-page .hc-dir-more{opacity:.72;font-size:12.5px;text-decoration:underline;text-underline-offset:3px}
#hc-page#hc-page .hc-dir-more svg{display:none}
</style>
<?php endif; ?>
<?php /* PHASE10D_2026-08-11 — grid geometry (HcGrid): columns per device, gaps,
        the Auto floor and the equal-height switch. Same contract as the rest. */ ?>
<?php $__gridCss = class_exists('\\OpsIQ\\Kb\\HcGrid') ? \OpsIQ\Kb\HcGrid::css($_settings) : ''; ?>
<?php if ($__gridCss !== ''): ?>
<style id="hc-gridgeo">
<?= $__gridCss ?></style>
<?php endif; ?>
<?php /* PHASE10E_2026-08-11 — chrome (HcChrome): search width, badge paint, the
        feedback block. Same contract. */ ?>
<?php $__chromeCss = class_exists('\\OpsIQ\\Kb\\HcChrome') ? \OpsIQ\Kb\HcChrome::css($_settings) : ''; ?>
<?php if ($__chromeCss !== ''): ?>
<style id="hc-chrome">
<?= $__chromeCss ?></style>
<?php endif; ?>
<?php if ($heroBgImage !== ''): /* PHASE_HC_MORE_CONFIG — hero background image + overlay. #hc-hero (id) outranks every .layout-* .hc-hero rule. */ ?>
<style id="hc-hero-image">
#hc-hero{background-image:linear-gradient(rgba(2,6,23,<?= $heroOverlay ?>),rgba(2,6,23,<?= $heroOverlay ?>)),url('<?= hc_esc(hc_asset_url($heroBgImage)) ?>')!important;background-size:cover!important;background-position:center center!important;background-repeat:no-repeat!important}
#hc-hero:before,#hc-hero:after{opacity:.4!important}
</style>
<?php endif; ?>
<?php if ($subheroBgImage !== ''): /* PHASE_HC_SUBHERO_IMAGE_2026-08-27 — the article/category header's own photograph.
     Same shape as the hero above: the id outranks every skin, the scrim keeps the title
     readable, and `background-origin:border-box` is restated because this element may be
     the one extended under the nav — a picture positioned against the old box would leave
     the top strip showing the layer underneath. */ ?>
<style id="hc-subhero-image">
#hc-subhero{background-image:linear-gradient(rgba(2,6,23,<?= $subheroOverlay ?>),rgba(2,6,23,<?= $subheroOverlay ?>)),url('<?= hc_esc(hc_asset_url($subheroBgImage)) ?>')!important;background-size:cover!important;background-position:center center!important;background-repeat:no-repeat!important;background-origin:border-box!important}
#hc-subhero:before,#hc-subhero:after{opacity:.4!important}
</style>
<?php endif; ?>
<?php if ($_i18nOn && $_i18nSwitcherNav): /* PHASE_HC_I18N — the language picker.
   Outside the dark block: it must look right in both themes. */ ?>
<?php /* PHASE0_2026-08-06 — was an inline <style id="hc-lang-css"> block. It is 100% static
        (no PHP interpolation at all), so it is now a cacheable asset in the SAME document
        position — a link element and a style element compete purely on document order, so the cascade
        is unchanged. hc_asset_url() is mandatory: on a proxied subdomain a bare /opsiq/...
        path returns text/html and nosniff blocks the stylesheet. */ ?>
<link rel="stylesheet" id="hc-lang-css" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-lang.css')) ?>">
<?php endif; ?>
<?php if ($darkEnabled && $darkToggleNav): /* PHASE_HC_DARK — the nav sun/moon.
   This block is NOT inside the dark stylesheet: the button has to look right in
   BOTH themes, and the dark block only exists to override light values. */ ?>
<?php /* PHASE0_2026-08-06 — was an inline <style id="hc-theme-tog-css"> block. It is 100% static
        (no PHP interpolation at all), so it is now a cacheable asset in the SAME document
        position — a link element and a style element compete purely on document order, so the cascade
        is unchanged. hc_asset_url() is mandatory: on a proxied subdomain a bare /opsiq/...
        path returns text/html and nosniff blocks the stylesheet. */ ?>
<link rel="stylesheet" id="hc-theme-tog-css" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-theme-toggle.css')) ?>">
<?php endif; ?>
<?php if ($darkEnabled): /* ── PHASE_HC_DARK ──────────────────────────────────────
   ONE dark palette for the whole Help Center. Every layout neutralises to the same
   greys; only cards, wrappers and buttons step apart so the hierarchy still reads.
   The BRAND colour survives untouched — it is the identity, and inverting it would
   make 20 themes look like one grey product.

   Why this is not just `:root[data-theme="dark"]{--card-bg:…}`: 349 layout rules
   paint `.hc-cat` and friends DIRECTLY, 32 of them at `.layout-x#hc-page …
   !important` — specificity (1,2,0). A variable override cannot beat a hardcoded
   colour. `html[data-theme="dark"] #hc-page .hc-cat` is (1,2,1) and, emitted last,
   wins every one of them without touching a single layout rule.

   It sits before the operator's Custom CSS so they can still override anything. */ ?>
<?php /* ── PHASE_HC_SURFACE_TOKENS_2026-08-15 ───────────────────────────────────────
 * THE PORTAL-NAMED TOKENS, DECLARED BY THE PAGE ITSELF.
 *
 * `--card`, `--ink`, `--bg` and `--line` are portal names that ~112 lines of
 * hc-sheet-6.css and 12 of hc-sheet-12.css paint from. Until now the ONLY place they
 * were declared was `<style id="px-ask-bridge">` further down — the chat assistant's
 * token bridge, which is emitted inside `if (assistant enabled)`.
 *
 * TWO DEFECTS FELL OUT OF THAT, and this block fixes both without touching the
 * bridge, which the assistant still needs: it mounts AFTER the footer, outside
 * #hc-page, so it cannot read anything declared here.
 *
 * 1. AN UNRELATED TOGGLE REPAINTED THE ARTICLE LIST. Switch the assistant off and the
 *    declarations vanished. `article_card_style = boxed` — the shipped default —
 *    falls back to `var(--ink,#0f172a)` for its rules, which is near-black and
 *    invisible on a #0b0f16 dark page. `category_sidebar_style = command` lost its
 *    shadow outright, because hc-sheet-12.css:8 uses `--ink` with NO fallback inside
 *    color-mix(), so the whole declaration was dropped as invalid at computed-value
 *    time. Verified live before this shipped: a render with hc_assistant_enabled=0
 *    contained zero `px-ask-bridge` while still serving sheet-12.
 *
 * 2. THEY COMPUTED AT :root, SO THE LAYOUT PALETTE WAS INVISIBLE. A custom property
 *    substitutes where it is DECLARED. `.layout-obsidian{--card-bg:#1a1a1a}` sits on
 *    #hc-page, a descendant of :root, so `:root{--card:var(--card-bg,#fff)}` resolved
 *    against :root's own `--card-bg` — plain `#fff`. Every rule painting from
 *    `var(--card)` was hardcoded white in light mode on all 20 layouts.
 *
 *    Declaring on #hc-page is the whole fix: the layout class is ON that element, so
 *    `var(--card-bg)` now resolves to the layout's own value.
 *
 * The dark half needs `html[data-theme="dark"] #hc-page`, not the bare attribute
 * selector: a declaration on the descendant wins for inheritance no matter what the
 * ancestor says, so a `html[data-theme="dark"]{...}` block alone would be shadowed by
 * the light block below on every element inside the page. */ ?>
<style id="hc-surface-tokens">
#hc-page{
  --accent: var(--brand,#6c5ce7);
  --card:   var(--card-bg,#fff);
  --bg:     var(--body-bg,#f7f8fc);
  --line:   var(--card-border,rgba(15,23,42,.12));
  --ink:    var(--text-primary,#0f172a);
  --ink-2:  var(--text-secondary,#475569);
  --ink-3:  var(--text-muted,#94a3b8);
}
html[data-theme="dark"] #hc-page{
  --card:  var(--hc-d-card,#151b25);
  --bg:    var(--hc-d-bg,#0b0f16);
  --line:  var(--hc-d-line,#2b3646);
  --ink:   var(--hc-d-ink,#e8edf5);
  --ink-2: var(--hc-d-ink-2,#a9b4c4);
  --ink-3: var(--hc-d-ink-3,#71809a);
}
</style>
<style id="hc-dark">
/* ── The palette ──────────────────────────────────────────────────────────────
   4 steps of depth: page → card → raised → hover. Enough to read hierarchy,
   never so much that it stripes. */
html[data-theme="dark"]{
  --hc-d-bg:<?= hc_esc($darkBg) ?>;
  --hc-d-card:<?= hc_esc($darkSurface) ?>;
  --hc-d-raised:#1e2733;
  --hc-d-hover:#243040;
  --hc-d-line:#2b3646;
  --hc-d-ink:<?= hc_esc($darkInk) ?>;
  --hc-d-ink-2:#a9b4c4;
  --hc-d-ink-3:#71809a;
  /* PHASE7_2026-08-07 — the danger trio, restated for dark. The light values are a
     near-WHITE chip (#fef2f2), which on an #0b0f16 page reads as a bright flash rather
     than a warning. Declared here, beside the rest of the dark palette, so anything
     that uses the tokens follows automatically. */
  --hc-danger:#fca5a5;
  --hc-danger-soft:rgba(185,28,28,.18);
  --hc-danger-line:rgba(252,165,165,.42);
  /* The tag chip inverts: a LIGHT wash on a dark card, and the readable ink step. */
  --text-2:var(--hc-d-ink-2);
  --card-2:rgba(255,255,255,.07);
  /* brand-as-TEXT, lifted until it clears AA on this surface. The FILL keeps the
     real brand (--brand) so white button text stays readable. */
  --brand-ink:<?= hc_esc($__brandInk) ?>;
}
</style>
<?php /* PHASE0.5_2026-08-08 — 14,887 bytes of static rules lifted to /opsiq/assets/hc-sheet-10.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-10.css')) ?>">
<?php /* Shared Help Center upgrade variants: loaded for standalone /help and unified /hc; Portal Native KB is unaffected. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-12.css')) ?>">
<style>


/* ── Dark-mode artwork ─────────────────────────────────────────────────────────
   Both logos are in the DOM; CSS picks one. These rules only exist when a dark
   logo was actually supplied — otherwise the light one is unclassed and serves
   both themes untouched. */
<?php if ($logoUrlDark !== ''): ?>
/* !important is load-bearing here: `.hc-logo img,.hc-logo-img{display:block}` is
   the SAME specificity (0,1,0) and sits later in the file, so it wins the tie and
   both logos render at once. */
.hc-logo-dark{display:none!important}
html[data-theme="dark"] .hc-logo-light{display:none!important}
html[data-theme="dark"] .hc-logo-dark{display:block!important}
<?php endif; ?>
<?php /* PHASE_HC_CUSTOM_CSS_LIFT_2026-08-17 — custom_css used to be emitted ONLY inside the
        unified-portal branch, so on every non-unified help center the Studio's Custom CSS
        box accepted code, reported saved, and was inert.

        HC_CUSTOM_CSS_LAST_2026-08-18 — it now emits ONCE as the LAST <style> before </body>
        (search for id="hc-custom-css" near the end of this file). Two reasons it moved OUT
        of this block: (1) it was opening <style id="hc-custom-css"> while ALREADY inside
        this open <style>, and HTML does not nest <style> — the inner </style> prematurely
        closed this block and dumped the trailing dark-mode rules AND their comments onto the
        page as visible text the instant custom_css was non-empty; (2) even un-nested it sat
        BEFORE later HC style blocks (px-unify, widget-layout), so operator overrides lost
        the cascade. Emitting it dead last makes operator CSS win every specificity TIE,
        which is what a "Custom CSS" box promises. */ ?>
<?php $__heroDark = trim((string)($_settings['hero_bg_image_dark'] ?? '')); if ($__heroDark !== ''): ?>
/* A dark hero photo, when the light one is too bright to sit under dark text. */
html[data-theme="dark"] #hc-page .hc-hero,
html[data-theme="dark"] #hc-page #hc-hero{
  background-image:url('<?= hc_esc(hc_asset_url($__heroDark)) ?>')!important;
  background-size:cover!important;background-position:center!important}
<?php endif; ?>
<?php $__subDark = trim((string)($_settings['subhero_bg_image_dark'] ?? '')); if ($__subDark !== ''): ?>
/* A dark header photo, for when the light one is too bright under a dark page. */
html[data-theme="dark"] #hc-page #hc-subhero{
  background-image:url('<?= hc_esc(hc_asset_url($__subDark)) ?>')!important;
  background-size:cover!important;background-position:center!important;
  background-origin:border-box!important}
<?php endif; ?>

/* Images should not glare out of a dark page. Logos are exempt: a dark-mode logo
   was drawn for this background and must not be dimmed on top of that. */
html[data-theme="dark"] #hc-page .hc-body img{filter:brightness(.92)}
html[data-theme="dark"] .hc-logo-img{filter:none!important}

/* NOTE: there is deliberately no `@media (prefers-color-scheme:dark)` block here.
   The OS preference is resolved by the boot script in <head>, which stamps
   data-theme before first paint — one source of truth, no duplicated palette, and
   the visitor's own choice always beats the OS because it is simply a later
   attribute value rather than a competing media query. */
</style>
<?php endif; ?>
<?php if (!empty($_widget)): /* PHASE_HC_WIDGET — the Help Center WIDGET shows this
   page inside a ~420px slide-in panel that already has its own header + close. The
   full-width home (hero + tile grid + featured row + nav + footer) does not belong in
   a narrow column, so this recasts it as a compact, single-column panel UI. Emitted
   ONLY when help_widget.php passes widget=1 — a plain ?embed=1 is untouched. It sits
   before the operator's Custom CSS so they can still override anything. */ ?>
<style id="hc-widget-layout">
/* The panel supplies its own chrome — the site header, footer and back-to-top are
   noise (and the mega nav cannot work at this width). */
#hc-page.hc-widget .hc-hdr,#hc-page.hc-widget #hc-top{display:none!important}

<?php
/* ON by default. Booleans MUST be handled before the string test: (string)false is
   '' — which is not in the false-list — so a plain !in_array() check would flip an
   explicit `false` back to true. */
$__wcRaw = $_settings['widget_clean_scroll'] ?? true;
$__wClean = is_bool($__wcRaw)
    ? $__wcRaw
    : (($__wcRaw === '' || $__wcRaw === null)
        ? true
        : !in_array(strtolower((string)$__wcRaw), ['0','off','false','no'], true));
?>
/* Sheet open = the sheet is the only thing that scrolls. Applies in BOTH scrollbar
   modes: it is what stops the page's rail sitting behind the sheet's. */
html.hc-widget-doc.hc-w-locked{overflow:hidden}
<?php if ($__wClean): ?>
/* ── Clean scrolling (opt-in) ────────────────────────────────────────────────
   No visible rail on the panel. It still scrolls by wheel, trackpad, touch and
   keyboard — the standard chat-widget approach. Paired with the host-page freeze in
   help_widget.php so nothing scrolls alongside the panel. No gutter is reserved
   here: with no scrollbar there is nothing to reserve for. */
html.hc-widget-doc{scrollbar-width:none!important;-ms-overflow-style:none!important;scrollbar-gutter:auto!important}
html.hc-widget-doc::-webkit-scrollbar{width:0!important;height:0!important;display:none!important}
<?php else: ?>
/* ── The panel's scrollbar ──────────────────────────────────────────────────
   Thin and branded rather than the OS default, which reads heavy at 420px.
   The gutter is reserved permanently so that locking the page scroll when the
   sheet opens cannot shift the content sideways by the scrollbar's width. */
html.hc-widget-doc{scrollbar-width:thin;scrollbar-color:rgba(var(--br),.34) transparent;scrollbar-gutter:stable}
html.hc-widget-doc::-webkit-scrollbar{width:3px;height:3px}
html.hc-widget-doc::-webkit-scrollbar-track{background:transparent}
html.hc-widget-doc::-webkit-scrollbar-thumb{border-radius:999px;background:transparent;box-shadow:none}html.hc-widget-doc:hover::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--brand) 30%,transparent);border:0}
html.hc-widget-doc::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb,var(--brand) 46%,transparent);border:0}
<?php endif; ?>
<?php if ($_widgetSide === 'right'): ?>
/* Panel docked RIGHT: its scrollbar would sit against the host page's own, two
   rails together. Flipping direction moves it to the panel's left (inner) edge,
   facing the page, then every top-level child flips straight back so nothing
   else about the layout changes.
   It has to be set on BODY as well: Blink takes the viewport scrollbar's side
   from the body's direction, so rtl on <html> alone does nothing. */
html.hc-widget-doc,html.hc-widget-doc>body{direction:rtl}
html.hc-widget-doc>body>*{direction:ltr}
<?php endif; ?>
#hc-page.hc-widget ~ .hc-foot,#hc-page.hc-widget .hc-foot{display:none!important}
</style>
<?php /* PHASE0.5_2026-08-08 — 12,631 bytes of static rules lifted to /opsiq/assets/hc-sheet-9.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-9.css')) ?>">
<?php if ($_wdVariant !== ''): /* PHASE_HC_WIDGET_LOOK — the skin, after the widget sheet so it
        wins on document order as well as specificity. Its tokens ride #hc-page as an
        inline style (see hc_build_view), not a <style> block. */ ?>
<link rel="stylesheet" id="hc-widget-look" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-widget-look.css')) ?>">
<script>
/* PHASE_HC_WIDGET_LOOK — the head's controls, bound ONCE by delegation so they survive the
   router's swap of #hc-page (a script inside swapped markup never runs). The language
   items are real links, so they work with no JavaScript at all; the theme toggle calls the
   Help Center's own API (which reports to the loader as it always has); close is a message
   to the loader on the host page. */
document.addEventListener('click', function (e) {
  /* A LANGUAGE PICK IS A REAL RELOAD, the Classic panel's way (owner, 2026-09-16: "change language
     suppose to change help center, /hc and portal language too … the other one would reload the
     page and as it reload auto open the widget"). These items are links, and the in-panel router
     took them over as a body-only swap: the frame never reloaded, never reported the language to
     the loader, never wrote hc_lang, so the /hc host and the portal (which follows hc_lang's storage
     event) never heard of it. OpsIQHelpLang.set() is the same call Classic's picker makes. */
  var li = e.target && e.target.closest ? e.target.closest('#hc-page .hc-wl-lang-item[data-lang]') : null;
  if (li && window.OpsIQHelpLang) {
    e.preventDefault(); e.stopImmediatePropagation();
    var code = li.getAttribute('data-lang');
    if (code === window.OpsIQHelpLang.get()) { var m0 = document.querySelector('#hc-page .hc-wl-lang-menu'); if (m0) m0.hidden = true; return; }
    window.OpsIQHelpLang.set(code);
    return;
  }
  var t = e.target && e.target.closest ? e.target.closest('[data-hc-wl]') : null;
  var menu = document.querySelector('#hc-page .hc-wl-lang-menu');
  if (!t) { if (menu && !menu.hidden && !(e.target.closest && e.target.closest('.hc-wl-lang'))) menu.hidden = true; return; }
  var k = t.getAttribute('data-hc-wl');
  if (k === 'lang' && menu) {
    e.stopPropagation(); menu.hidden = !menu.hidden; t.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
    /* The head clips its own decoration (overflow:hidden) and the search sits over its lower edge,
       so a menu hung inside it opened BEHIND the search (owner, 2026-09-16: "needs to be above").
       Opened, it floats in the frame's viewport under its button instead. */
    if (!menu.hidden) {
      var r = t.getBoundingClientRect(), vw = document.documentElement.clientWidth, vh = window.innerHeight;
      menu.classList.add('hc-wl-lang-float');
      menu.style.top = Math.round(r.bottom + 8) + 'px';
      menu.style.right = Math.max(8, Math.round(vw - r.right)) + 'px';
      menu.style.maxHeight = Math.max(160, Math.min(280, Math.round(vh - r.bottom - 24))) + 'px';
    }
  }
  else if (k === 'theme' && window.OpsIQHelpTheme) { window.OpsIQHelpTheme.toggle(); }
  else if (k === 'close') { try { parent.postMessage({ type: 'opsiq-help-close' }, '*'); } catch (err) {} }
});
/* "Show more", the storefront's way: the list grows in place, the reader keeps their spot.
   A category renders every card and reveals 30 more per click; a search fetches its next
   page (the Help Center's own ?page=N) and appends that page's cards. */
document.addEventListener('click', function (e) {
  var b = e.target && e.target.closest ? e.target.closest('[data-hc-wl-more]') : null;
  if (!b) return;
  e.preventDefault();
  var list = document.querySelector('#hc-page [data-hc-wl-list]');
  var mode = b.getAttribute('data-hc-wl-more');
  if (mode === 'reveal') {
    var hidden = list ? list.querySelectorAll('.hc-wl-hidden') : [];
    for (var i = 0; i < hidden.length && i < 30; i++) hidden[i].classList.remove('hc-wl-hidden');
    if (!list || !list.querySelector('.hc-wl-hidden')) b.remove();
    return;
  }
  b.disabled = true;
  var u = mode + (mode.indexOf('?') >= 0 ? '&' : '?') + '_hcajax=1';
  fetch(u, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
    .then(function (r) { return r.json(); })
    .then(function (data) {
      var tmp = document.createElement('div'); tmp.innerHTML = (data && data.html) || '';
      var more = tmp.querySelector('[data-hc-wl-list]');
      if (list && more) { while (more.firstChild) list.appendChild(more.firstChild); }
      var next = tmp.querySelector('[data-hc-wl-more]');
      if (next) { b.setAttribute('data-hc-wl-more', next.getAttribute('data-hc-wl-more')); b.disabled = false; }
      else b.remove();
    })
    .catch(function () { b.disabled = false; });
});
document.addEventListener('keydown', function (e) {
  if (e.key !== 'Escape') return;
  var menu = document.querySelector('#hc-page .hc-wl-lang-menu');
  if (menu && !menu.hidden) menu.hidden = true;
});
/* THE SEARCH IS THE STOREFRONT PANEL'S: results as you type, in the body, never a page load
   (owner, 2026-09-14: "the search is still using the old search system and load to it. Check
   how the storefront help center does it, it show results as you type").

   Same shape as opsiq/js/opsiq_storefront_help.js: an input listener, a 280 ms debounce, an
   empty box goes home, anything else asks for that term. What it asks is the Help Center's
   OWN search view over ?_hcajax=1 — so the ranking, the gating, the translations, the cards
   and the "Show more" are the ones the rest of this look already uses, and only the body is
   replaced. The head, the search and the caret never move. */
(function () {
  var SEARCH_URL = <?= json_encode(hc_u('q=__HCQ__'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var HOME_URL   = <?= json_encode(hc_u(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var FAILED     = <?= json_encode((string)$__t('load_failed', 'Could not load that help page.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var timer = 0, ctrl = null;
  function err(show) {
    var e = document.querySelector('#hc-page [data-hc-wl-err]');
    if (!e) return;
    e.textContent = show ? FAILED : '';
    e.hidden = !show;
  }
  function ask(url) {
    var body = document.querySelector('#hc-page .hc-wl-body');
    if (!body) return;
    /* A keystroke cancels the answer to the one before it, so results can never
       arrive out of order and paint an older term's list. */
    if (ctrl) { try { ctrl.abort(); } catch (e) {} }
    ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
    var opts = { credentials: 'same-origin', headers: { 'Accept': 'application/json' } };
    if (ctrl) opts.signal = ctrl.signal;
    err(false);
    body.classList.add('hc-wl-busy');
    fetch(url + (url.indexOf('?') >= 0 ? '&' : '?') + '_hcajax=1', opts)
      .then(function (r) { return r.json(); })
      .then(function (data) {
        if (!data || !data.ok) throw new Error('bad');
        var tmp = document.createElement('div'); tmp.innerHTML = data.html;
        var newBody = tmp.querySelector('.hc-wl-body'), newPage = tmp.querySelector('#hc-page');
        var live = document.querySelector('#hc-page .hc-wl-body'), page = document.getElementById('hc-page');
        if (!live || !newBody) throw new Error('shape');
        live.classList.remove('hc-wl-busy');
        live.innerHTML = newBody.innerHTML;
        if (page && newPage) page.className = newPage.className;
        live.scrollTop = 0;
        if (typeof window.hcInitView === 'function') { try { window.hcInitView(); } catch (e) {} }
      })
      .catch(function (e) {
        if (e && e.name === 'AbortError') return;
        var live = document.querySelector('#hc-page .hc-wl-body');
        if (live) live.classList.remove('hc-wl-busy');
        /* Never silent: a search that answers nothing and says nothing reads as broken. */
        try { if (window.console && console.error) console.error('[hc widget search]', e); } catch (x) {}
        err(true);
      });
  }
  function go(term) { ask(term === '' ? HOME_URL : SEARCH_URL.replace('__HCQ__', encodeURIComponent(term))); }
  document.addEventListener('input', function (e) {
    var i = e.target;
    if (!i || i.id !== 'hc-hero-input') return;
    clearTimeout(timer);
    var term = String(i.value || '').trim();
    timer = setTimeout(function () { go(term); }, 280);
  });
  /* Enter answers here instead of loading the search page into the panel. */
  document.addEventListener('submit', function (e) {
    var f = e.target;
    if (!f || f.id !== 'hc-hero-form') return;
    e.preventDefault();
    clearTimeout(timer);
    var i = document.getElementById('hc-hero-input');
    go(i ? String(i.value || '').trim() : '');
  });
})();
/* The reader's actions (copy link / share / print) and the copy button on each code block:
   the Help Center's own behaviours, bound once by delegation because the router swaps the
   body. navigator.share when the browser has it (a phone gets the real sheet), else the
   URL is copied; the clipboard API being PRESENT is not success (it rejects outside a
   secure context and without a gesture), so a rejection falls through to the textarea. */
document.addEventListener('click', function (e) {
  var b = e.target && e.target.closest ? e.target.closest('[data-hc-wl-act],.hc-wl-cc-btn') : null;
  if (!b) return;
  var act = b.getAttribute('data-hc-wl-act') || 'code';
  if (act === 'print') { window.print(); return; }
  var text = location.href;
  if (act === 'code') {
    var pre = b.closest('pre'); if (!pre) return;
    var c = pre.cloneNode(true); c.querySelectorAll('.hc-wl-cc-btn').forEach(function (n) { n.remove(); });
    text = c.textContent.replace(/\s+$/, '');
  }
  if (act === 'share' && navigator.share) { navigator.share({ title: document.title, url: text }).catch(function () {}); return; }
  var done = function () {
    if (b.dataset.busy) return;
    var lab = b.querySelector('span') || b; b.dataset.busy = '1';
    var was = lab.textContent; lab.textContent = b.getAttribute('data-copied') || 'Copied'; b.classList.add('is-done');
    setTimeout(function () { lab.textContent = was; b.classList.remove('is-done'); delete b.dataset.busy; }, 1600);
  };
  var legacy = function () {
    var t = document.createElement('textarea'); t.value = text; t.setAttribute('readonly', ''); t.style.cssText = 'position:absolute;left:-9999px';
    document.body.appendChild(t); t.select(); try { document.execCommand('copy'); } catch (err) {} document.body.removeChild(t); done();
  };
  if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(legacy); else legacy();
});
/* Code-block copy buttons (reader_code_copy), re-armed whenever the router swaps markup
   (a MutationObserver, not a per-view script).
   2026-09-15 — the reading-progress half of this block went with the bar itself: this look
   renders no .hc-wl-progress, so the body scroll listener it drove had nothing to paint. */
(function () {
  var LABEL = <?= json_encode($__t('act_copy', 'Copy link') === 'Copy link' ? 'Copy' : $__t('act_copy', 'Copy')) ?>;
  var DONE = <?= json_encode($__t('act_copied', 'Copied')) ?>;
  var queued = false;
  function decorate() {
    var c = document.querySelector('#hc-page .hc-wl-content.hc-wl-cc'); if (!c) return;
    c.querySelectorAll('pre').forEach(function (pre) {
      if (pre.querySelector('.hc-wl-cc-btn')) return;
      var b = document.createElement('button'); b.type = 'button'; b.className = 'hc-wl-cc-btn'; b.textContent = LABEL; b.setAttribute('data-copied', DONE); pre.appendChild(b);
    });
  }
  function arm() {
    queued = false;
    decorate();
  }
  /* This block sits in the head: the body may not exist yet. */
  function start() {
    arm();
    new MutationObserver(function () { if (!queued) { queued = true; requestAnimationFrame(arm); } }).observe(document.body, { childList: true, subtree: true });
  }
  if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
})();
</script>
<?php endif; ?>
<style>
</style>
<?php endif; ?>
<?php if (empty($_widget) && $mobileWidgetView): ?>
<?php /* PHASE0_2026-08-06 — was an inline <style id="hc-mobile-widget-layout"> block. It is 100% static
        (no PHP interpolation at all), so it is now a cacheable asset in the SAME document
        position — a link element and a style element compete purely on document order, so the cascade
        is unchanged. hc_asset_url() is mandatory: on a proxied subdomain a bare /opsiq/...
        path returns text/html and nosniff blocks the stylesheet. */ ?>
<link rel="stylesheet" id="hc-mobile-widget-layout" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-mobile-widget.css')) ?>">
<?php endif; ?>
<?php /* Custom CSS is emitted AFTER the portal chrome further down, not here:
        theme_css + chrome_css are printed in <body>, so anything placed in the
        head lost to them. The Studio calls this "applied on top of your design",
        and it has to actually be last to be that. */ ?>
<?php /* PHASE_HC_FOCUS_MODALITY_2026-08-27 — THE RING WAS PAINTED ON PAGE LOAD, FOR EVERYONE.

        Owner: "this blue focus border put in all search in HC, remove it... let them
        retain their normal look on hover or focus" — and then, on a second screenshot,
        "its just everywhere".

        It was. The hero search input carries `autofocus` on all twenty layouts, and a
        focused TEXT FIELD always matches :focus-visible whatever the input modality. So
        the shell's :focus-within ring was the RESTING state of every page with a hero
        search: not a focus indicator, a permanent blue box around the search bar.

        CSS cannot tell autofocus or a click apart from a Tab, so the modality is recorded
        here and hc-sheet-6.css reads it. THE FLAG SUPPRESSES rather than enables, which
        is the direction that fails safe: with JavaScript off the attribute is never
        written and the keyboard ring is still drawn. It is stamped in <head>, before the
        body paints, so there is no flash of a ring on load. */ ?>
<script>
(function(){
  var de = document.documentElement;
  /* Assume pointer until a key says otherwise: the load-time autofocus must not ring. */
  function ptr(){ de.setAttribute('data-hc-pointer',''); }
  ptr();
  document.addEventListener('keydown', function(e){
    var k = e.key || '';
    if (k === 'Tab' || k.slice(0,5) === 'Arrow' || k === 'Home' || k === 'End') de.removeAttribute('data-hc-pointer');
  }, true);
  document.addEventListener('pointerdown', ptr, true);
  document.addEventListener('mousedown', ptr, true);
})();
</script>
</head>
<body>

<?php /* PHASE_PORTAL_P7.5 — UNIFY: on the branded host with the toggle on, the
        help center wears the portal's chrome (theme + nav + footer) so it looks
        identical to the ticket portal. help.php's own header/footer are hidden. */ ?>
<?php if ($__helpUnified && is_array($__portalDesignBundle)): ?>
<style id="px-unify">
<?= (string)($__portalDesignBundle['theme_css'] ?? '') ?>

<?= (string)($__portalDesignBundle['chrome_css'] ?? '') ?>

<?php /* PHASE0_2026-08-06 — portal-hero.css moved OUT of this block. It is ~94 KB of
        STATIC stylesheet that was read from disk and re-inlined on every single /hc
        request, with no cache, no ETag and no conditional GET. It is now a real
        stylesheet link emitted immediately below this <style> element and immediately
        above the custom-CSS block, which preserves its exact cascade position between
        chrome_css and custom_css — a link and a style element compete purely on
        document order, so the ties it used to win it still wins.
        hc_asset_url() is mandatory here: on a proxied subdomain a bare /opsiq/... path
        returns text/html and nosniff blocks the file (same reason portal-assistant.css
        uses it). Cache-busted by filemtime, so an edit to the asset still ships. */ ?>
</style>
<?php if ($__usePortalHero): ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/portal-hero.css')) ?>">
<?php endif; ?>
<style id="px-unify-after">

<?php /* Studio Custom CSS LAST — it rides inside theme_css above, which is
        printed before chrome_css, so on this surface it lost every
        equal-specificity tie to the chrome. Re-emitting it here makes
        "applied on top of your design" true on the Help Centre too. */ ?>
<?= (string)($__portalDesignBundle['custom_css'] ?? '') ?>

/* PHASE_PORTAL_TWO_WAY_HERO — the Portal hero is rendered outside .hc-hero,
   so none of the twenty theme-specific hero selectors can reshape it. */
<?php /* PHASE10K9 — #hc-portal-hero's frame rules now live in hc-sheet-6.css. */ ?>

body{font-family:var(--portal-font,inherit)!important}
#hc-page .hc-main{font-size:15px;line-height:1.55}
<?php if ($__useUnifiedNav): ?>#hc-hdr,.hc-hdr{display:none!important}<?php endif; ?>
<?php /* Hide the help centre's own footer ONLY when the portal footer actually renders.
        This used to test just "the bundle has a footer", which with matching off (or now, with
        the footer set to 'hc') hid the local footer while the portal one was never emitted —
        leaving the page with no footer at all. */ ?>
<?php if ($__useUnifiedFooter): ?>#hc-foot,.hc-foot{display:none!important}<?php endif; ?>
<?php /* PHASE_PORTAL_P7.5 — KEEP the help center's own HERO design (sonar radar,
        big search, etc.); match only the CONTENT below it (page background,
        cards, sidebar, articles) to the portal's light palette. The hero (#hc-hero)
        is deliberately NOT touched here. */ ?>
#hc-page{--pcard:var(--card,#fff);--pink:var(--ink,#0f172a);--pink2:var(--ink-2,#475569);--pline:var(--line,#e2e8f0)}
/* Some HC layouts set -webkit-text-fill-color on ancestors. Re-anchor the
   Portal-owned support CTA so its real label and arrow remain readable. */
#hc-page .hc-help-card-btn,
#hc-page .hc-help-card-btn :where(span,svg,path){color:var(--on-accent,#fff)!important;-webkit-text-fill-color:var(--on-accent,#fff)!important}
#hc-page .hc-help-card-btn{background:var(--accent-bg,var(--accent))!important;border-color:transparent!important}
</style>
<?php /* PHASE0.5_2026-08-08 — 9,530 bytes of static rules lifted to /opsiq/assets/hc-sheet-11.css.
        It is emitted AT THIS EXACT POSITION on purpose: a link element and a
        style element compete purely on document order, so moving the tag would
        reorder the cascade even though every declaration is unchanged. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-sheet-11.css')) ?>">
<?php /* PHASE_PORTAL_SUBHERO_MATCH — the page-header band adopts the portal
        palette. Static, so it is a linked sheet (the inline budget is capped by
        HcCssExtractionTest); emitted here, inside the matching branch, so the
        gate is unchanged and the file is never requested with matching off.
        See the sheet's own header for why the band needs remapping at all. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/hc-portal-subhero.css')) ?>">
<style>
</style>
<?php /* custom_css now emits at top level (see PHASE_HC_CUSTOM_CSS_LIFT); not repeated here. */ ?>
<?php if (!empty($__portalDesignBundle['announce_html'])): ?><div id="px-announce"><?= $__portalDesignBundle['announce_html'] ?></div><?php endif; ?>
<?php if ($__useUnifiedNav): ?><div id="px-nav"><?= $__portalDesignBundle['nav_html'] ?></div>
<script>(function(){
var b=document.querySelector('#px-nav .pnav-burger'),n=b&&b.closest('.pnav');if(!b||!n)return;
var panel=n.querySelector('.pnav-items'),scrim=n.querySelector('.pnav-scrim'),overlay=/pnavm-(drawer|sheet|fullscreen)/.test(n.className),last=null;
function closeMenus(except){n.querySelectorAll('.pnav-dd-open').forEach(function(dd){if(dd===except)return;dd.classList.remove('pnav-dd-open');var t=dd.querySelector(':scope > .pnav-dd-btn');if(t)t.setAttribute('aria-expanded','false');});}
function openNav(open){n.classList.toggle('pnav-open',open);b.setAttribute('aria-expanded',open?'true':'false');if(scrim)scrim.hidden=!open;if(overlay)document.body.classList.toggle('pnav-locked',open);if(open){last=document.activeElement;var first=panel&&panel.querySelector('a,button');if(first)first.focus();}else if(last&&last.focus){last.focus();last=null;}}
b.addEventListener('click',function(){openNav(!n.classList.contains('pnav-open'));});
n.addEventListener('click',function(e){if(e.target.closest('[data-navclose]')){e.preventDefault();openNav(false);}});
n.querySelectorAll('.pnav-dd-btn').forEach(function(x){x.addEventListener('click',function(e){var dd=x.parentNode,open=!dd.classList.contains('pnav-dd-open');closeMenus(dd);dd.classList.toggle('pnav-dd-open',open);x.setAttribute('aria-expanded',open?'true':'false');if(window.matchMedia('(max-width:720px)').matches)e.preventDefault();});x.addEventListener('keydown',function(e){if(e.key==='ArrowDown'){e.preventDefault();x.click();var id=x.getAttribute('aria-controls'),p=id&&document.getElementById(id),f=p&&p.querySelector('a,button');if(f)f.focus();}});});
n.querySelectorAll('.pnav-sub > .pdd-has').forEach(function(a){a.addEventListener('click',function(e){if(window.matchMedia('(max-width:720px)').matches){e.preventDefault();a.parentNode.classList.toggle('pnav-dd-open');}});});
document.addEventListener('keydown',function(e){if(e.key!=='Escape')return;var t=n.querySelector('.pnav-dd-open > .pnav-dd-btn');if(t){closeMenus();t.focus();e.preventDefault();}else if(n.classList.contains('pnav-open')){openNav(false);e.preventDefault();}});
document.addEventListener('click',function(e){if(!n.contains(e.target))closeMenus();});
n.addEventListener('keydown',function(e){if(e.key!=='Tab'||!overlay||!n.classList.contains('pnav-open')||!panel)return;var f=panel.querySelectorAll('a[href],button:not([disabled])');if(!f.length)return;if(e.shiftKey&&document.activeElement===f[0]){e.preventDefault();f[f.length-1].focus();}else if(!e.shiftKey&&document.activeElement===f[f.length-1]){e.preventDefault();f[0].focus();}});
window.addEventListener('resize',function(){if(window.matchMedia('(min-width:721px)').matches&&n.classList.contains('pnav-open'))openNav(false);});
})();</script>
<?php
/* PHASE_HC_ACCOUNT_CHIP — the sign-in / account control the portal has and the help centre did not.
 *
 * The two navs looked different for exactly one reason: .pnav-in is justify-content:space-between,
 * and the portal has THREE children (brand | items | #px-acct) while the help centre had two — so
 * its links were pushed to the far right instead of sitting centred. Same design, same classes,
 * one missing element. Adding the real control fixes the alignment as a side effect and, more to
 * the point, lets a signed-in customer reach their requests from the help centre.
 *
 * Rendered from the SERVER session (this surface is same-origin with the portal), so there is no
 * signed-out flash and no extra request. attemptLogin=false: reading who is here must never mint
 * a session as a side effect of viewing a public page. */
$__hcAcct = null;
/* help.php deliberately does NOT load opsiq.php (it renders admin UI), and it only pulls in
 * opsiq.portal_design.php — so the identity helpers are simply absent here and every
 * function_exists() check below would silently answer false. Guard-load the two modules the
 * chip needs, exactly as the design engine is loaded above. Wrapped so a load failure can
 * never take the help centre down: worst case the chip is skipped. */
if ($__useUnifiedNav) {
    try {
        if (!function_exists('opsiq_portal_experience_enabled')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_experience.php';
            if (is_file($__f)) require_once $__f;
        }
        if (!function_exists('opsiq_portal_identity_current')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_identity.php';
            if (is_file($__f)) require_once $__f;
        }
    } catch (\Throwable $e) { /* chip skipped */ }
}
if ($__useUnifiedNav && function_exists('opsiq_portal_experience_enabled') && opsiq_portal_experience_enabled()) {
    $__hcPortalBase = function_exists('opsiq_portal_public_base')
        ? (string)opsiq_portal_public_base((string)$_siteKey)
        : '';
    if ($__hcPortalBase !== '') {
        $__hcMe = null;
        if (function_exists('opsiq_portal_identity_for_workspace')) {
            try { $__hcMe = opsiq_portal_identity_for_workspace((string)$_siteKey); } catch (\Throwable $e) { $__hcMe = null; }
        }
        $__hcSep = (strpos($__hcPortalBase, '?') === false) ? '?' : '&';
        $__hcAcct = [
            'base'      => $__hcPortalBase . $__hcSep,
            'login_url' => hc_portal_login_url(),
            'signed_in' => is_array($__hcMe) && !empty($__hcMe['signed_in']),
            'name'      => is_array($__hcMe) ? trim((string)($__hcMe['name'] ?? '')) : '',
            'email'     => is_array($__hcMe) ? trim((string)($__hcMe['email'] ?? '')) : '',
            'site_key'  => (string)($_siteKey ?? ''),
        ];
        if ($__hcAcct["signed_in"] && $__hcAcct["name"] === "") $__hcAcct["name"] = $__t("account", "Account");
        $__hcAcct["i18n"] = [
            "my_requests" => $__t("my_requests", "My requests"),
            "submit_request" => $__t("submit_request", "Submit a request"),
            "sign_out" => $__t("sign_out", "Sign out"),
            "sign_in" => $__t("sign_in", "Sign in"),
        ];
    }
}
?>
<?php if ($__hcAcct): ?>
<script>(function(){
  var A = <?= json_encode($__hcAcct, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  /* A bring-your-own nav names its end slot with {{account}}; the chip goes there. */
  var slot = document.querySelector('#px-nav .pnav-custom .pnav-var-account');
  var host = slot || document.querySelector('#px-nav .pnav-in');
  if (!host || document.getElementById('px-acct')) return;
  function esc(x){ var d=document.createElement('div'); d.textContent=x==null?'':String(x); return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
  var w = document.createElement('div'); w.id='px-acct'; w.className='px-acct';
  if (A.signed_in){
    var ini = A.name.trim().split(/\s+/).map(function(x){return x[0]||'';}).slice(0,2).join('').toUpperCase() || 'U';
    w.innerHTML = '<button type="button" class="px-acct-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="hc-acct-menu"><span class="px-acct-av">'+esc(ini)+'</span><span>'+esc(A.name.split(' ')[0])+'</span>'
      + '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>'
      + '<div class="px-acct-menu" id="hc-acct-menu" role="menu">'
      /* Full name, email below: the same header the portal's menu opens with. */
      + (function(){ var nm=String(A.name||'').trim(), em=String(A.email||'').trim(), top=nm||em, sub=(nm&&em&&em.toLowerCase()!==nm.toLowerCase())?em:'';
          return top ? '<div class="px-acct-who" role="presentation"><b title="'+esc(top)+'">'+esc(top)+'</b>'+(sub?'<small title="'+esc(sub)+'">'+esc(sub)+'</small>':'')+'</div><div class="sep" role="separator"></div>' : ''; })()
      + '<a role="menuitem" href="'+esc(A.base)+'p=requests">'+esc(A.i18n.my_requests)+'</a>'
      + '<a role="menuitem" href="'+esc(A.base)+'p=new">'+esc(A.i18n.submit_request)+'</a>'
      + '<div class="sep" role="separator"></div>'
      + '<button type="button" role="menuitem" id="hc-signout">'+esc(A.i18n.sign_out)+'</button>'
      + '</div>';
    var btn = w.querySelector('.px-acct-btn'),menu=w.querySelector('.px-acct-menu');
    function setOpen(open,focus){w.classList.toggle('open',open);btn.setAttribute('aria-expanded',open?'true':'false');if(open){var first=menu.querySelector('[role="menuitem"]');if(first)first.focus();}else if(focus)btn.focus();}
    btn.addEventListener('click', function(e){ e.stopPropagation(); setOpen(!w.classList.contains('open'),false); });
    w.addEventListener('keydown',function(e){var items=Array.from(menu.querySelectorAll('[role="menuitem"]')),i=items.indexOf(document.activeElement);if(e.key==='Escape'){e.preventDefault();setOpen(false,true);}else if((e.key==='ArrowDown'||e.key==='ArrowUp')&&items.length){e.preventDefault();i=i<0?0:(i+(e.key==='ArrowDown'?1:-1)+items.length)%items.length;items[i].focus();}});
    document.addEventListener('click', function(e){ if(!w.contains(e.target))setOpen(false,false); });
    /* The portal has no ?p=signout route — sign-out is an action, not a page. Call the same
       endpoint the portal calls, and honour the workspace's sign-out propagation so ending the
       session here also ends it in the app that granted it. */
    w.querySelector('#hc-signout').addEventListener('click', function(){
      var fd = new FormData(); fd.append('site_key', A.site_key || '');
      var csrf=''; try{ csrf=(document.cookie.match(/(?:^|;\s*)opsiq_csrf=([^;]+)/)||[])[1]||''; csrf=decodeURIComponent(csrf); }catch(e){}
      fetch('/opsiq/ajax_api.php?ajax=portal_logout', {method:'POST', body:fd, credentials:'same-origin', headers:{'X-OpsIQ-CSRF':csrf}})
        .then(function(r){ return r.json(); })
        .then(function(j){ location.href = (j && j.signout_url) ? j.signout_url : location.href; })
        .catch(function(){ location.reload(); });
    });
  } else {
    /* Straight to the portal's sign-in route — it owns every method, including the
       operator's own external login when that is configured. Never a second copy here. */
    w.innerHTML = '<a class="px-acct-signin" href="'+esc(A.login_url || (A.base+'p=signin'))+'">'+esc(A.i18n.sign_in)+'</a>';
  }
  host.appendChild(w);
})();</script>
<?php endif; ?>

<?php /* PORTAL_NAV_HC_THEME_TOGGLE_2026-08-23: portal nav_html is server-rendered
        on /HC, so portal.php's client utility builder never runs here. Mount the
        same sun/moon control into the shared end slot. hcInitThemeToggle() binds
        it to the no-flash theme API; Portal Studio flags decide if it exists. */ ?>
<?php if ($__useUnifiedNav && $darkEnabled && $darkToggleNav): ?>
<style id="hc-portal-theme-toggle-css">
#px-nav .px-navend .hc-theme-tog{width:34px;height:34px;flex:0 0 34px;color:inherit}
@media(max-width:760px){#px-nav .px-navend .hc-theme-tog{width:40px;height:40px;flex-basis:40px}}
</style>
<script>(function(){
  var host=document.querySelector('#px-nav .pnav-in')||(document.querySelector('#px-nav .pnav-custom .pnav-var-account')?document.querySelector('#px-nav .pnav-custom'):null);
  if(!host||document.getElementById('hc-theme-tog'))return;
  var end=host.querySelector('.px-navend');
  if(!end){end=document.createElement('div');end.className='px-navend';host.appendChild(end);}
  var b=document.createElement('button');b.type='button';b.id='hc-theme-tog';b.className='hc-theme-tog';
  b.setAttribute('aria-label',<?= json_encode((string)$__t('theme_switch', 'Switch between light and dark'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
  b.title=<?= json_encode((string)$__t('theme_toggle', 'Light / dark'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  b.innerHTML='<svg class="hc-tog-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><svg class="hc-tog-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 1.6v2.2M12 20.2v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M1.6 12h2.2M20.2 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6"/></svg>';
  var acct=document.getElementById('px-acct');
  if(acct&&acct.parentNode!==end)end.appendChild(acct);
  if(acct)end.insertBefore(b,acct);else end.appendChild(b);
})();</script>
<?php endif; ?>

<?php /* PHASE_HC_NAV_LANG — the language picker survives the nav swap.
 *
 * With nav = portal the help centre drops its OWN header (hc_render_nav is never called), and
 * the portal's nav_html carries no picker — it is built client-side by portal.php, which does
 * not run here. So choosing the portal nav silently removed the language switcher from every
 * help centre page, while the languages themselves stayed enabled. One picker, both surfaces:
 * this renders the same control the portal builds, from the same nav classes, so it inherits
 * whatever nav design the operator picked. The rows are REAL ?lang= links (this surface is
 * server-translated), which is what the help centre's own picker uses. */ ?>
<?php if ($__useUnifiedNav && $_i18nOn && $_i18nSwitcherNav && count($_i18nLocales) > 1): ?>
<style>
/* .px-lang lives in portal-core.css, which this surface never loads — only chrome_css. The
   handful of rules the control needs beyond the nav's own are inlined rather than pulling in
   a whole stylesheet for one widget. */
/* Vertical padding kept so the trigger shares the nav's line box; only the horizontal
   padding, border, radius and fill go. Font untouched — .pnav-link already sets it. */
#px-nav .px-navend{display:inline-flex;align-items:center;gap:12px;flex:none}
#px-nav .px-lang{display:inline-flex;align-items:center}
#px-nav .px-lang .pnav-dd-btn{background:none;border:0;border-radius:0;padding-left:0;padding-right:0;box-shadow:none;gap:6px;display:inline-flex;align-items:center;cursor:pointer;color:inherit}
#px-nav .px-lang .px-lang-code{letter-spacing:.02em}
#px-nav .px-lang .pnav-ind{margin-left:1px}
#px-nav .px-lang .px-lang-flag{flex:0 0 auto;display:block;width:18px;height:14px;object-fit:cover;border-radius:2.5px;box-shadow:0 0 0 1px rgba(15,23,42,.10)}
#px-nav .px-lang .pnav-dd-menu{min-width:186px}
#px-nav .px-lang .pdd-row{display:flex;align-items:center;justify-content:flex-start;gap:10px;width:100%;padding:8px 10px;font-size:13.5px;text-align:left;text-decoration:none;color:inherit}
#px-nav .px-lang .pdd-tx{flex:1 1 auto;min-width:0}
#px-nav .px-lang .pdd-l{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#px-nav .px-lang .pdd-row.is-active{color:var(--brand-ink,var(--brand));font-weight:800}
#px-nav .px-lang .px-lang-tick{flex:none;margin-left:auto;color:var(--brand-ink,var(--brand))}
/* The shared nav compiled chrome has generic mobile accordion rules, but not the
   exception for a language picker mounted in .px-navend. Without this, the trigger
   inherits width:100%, its menu collapses to max-height:0, and the only picker on
   the unified Help Center is unusable on a phone. */
@media(max-width:760px){
  #px-nav .pnav-in>.px-navend{order:1;margin-left:auto;width:auto;flex:0 0 auto;align-items:center;position:relative}
  #px-nav .pnav-in>.pnav-burger{order:2;margin-left:8px}
  #px-nav .pnav-in>.pnav-items{order:3}
  #px-nav .px-navend .pnav-dd{width:auto;flex:0 0 auto}
  #px-nav .px-navend .px-lang .pnav-dd-btn{padding:0 8px;min-height:40px;font-size:12.5px;line-height:1;display:inline-flex;align-items:center;gap:6px}
  #px-nav .px-navend .px-lang .px-lang-code{font-size:12px}
  #px-nav .px-navend .px-lang .pnav-dd-menu{position:absolute;top:calc(100% + 8px);right:0;left:auto;z-index:80;width:max-content;min-width:190px;max-width:calc(100vw - 24px);max-height:min(60vh,340px);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:6px;border:1px solid var(--line);border-radius:12px;background:var(--card);box-shadow:0 18px 40px -14px rgba(15,23,42,.3);opacity:0;visibility:hidden;transform:translateY(-4px);pointer-events:none;transition:opacity .16s var(--ease,ease),transform .16s var(--ease,ease),visibility .16s}
  #px-nav .px-navend .px-lang.pnav-dd-open .pnav-dd-menu{opacity:1;visibility:visible;transform:none;pointer-events:auto}
  #px-nav .px-navend .px-lang .pnav-dd-menu .pdd-row{width:100%;padding:9px 10px;border-radius:8px}
}
</style>
<script>(function(){
  var host = document.querySelector('#px-nav .pnav-in') || (document.querySelector('#px-nav .pnav-custom .pnav-var-account') ? document.querySelector('#px-nav .pnav-custom') : null);
  if (!host || document.getElementById('px-nav-lang')) return;
  var box = document.createElement('div');
  box.id = 'px-nav-lang'; box.className = 'pnav-dd px-lang';
  box.innerHTML = <?php
      /* Built HERE, not borrowed: $__LL / $__curShort are locals of hc_render_nav(), which is
         not even called on this surface (the portal nav replaced it). Reaching for them left
         this string empty and the picker rendered as an empty box. */
      $__nlAll   = function_exists('opsiq_portal_locales') ? opsiq_portal_locales() : [];
      $__nlShort = opsiq_hc_locale_badge($_locale)[1] ?? strtoupper(explode('-', (string)$_locale)[0]);
      $__nlRows  = '';
      foreach ($_i18nLocales as $__nlC) {
          $__nlM = $__nlAll[$__nlC] ?? null;
          if (!$__nlM) continue;
          /* Same rule as the two pickers above: name the locale explicitly or the cookie wins. */
          $__nlHref = hc_u_lang($__nlC);
          $__nlRows .= '<a class="pdd-row hc-lang-item' . ($__nlC === $_locale ? ' is-active' : '') . '" role="menuitem"'
              . ' href="' . hc_esc($__nlHref) . '" lang="' . hc_esc($__nlC) . '"'
              . ' data-lang="' . hc_esc($__nlC) . '"'
              . ' dir="' . hc_esc(opsiq_portal_locale_dir($__nlC)) . '">'
              . hc_lang_flag_img($__nlC)
              . '<span class="pdd-tx"><span class="pdd-l">' . hc_esc((string)$__nlM['native']) . '</span></span>'
              . ($__nlC === $_locale
                  ? '<svg class="px-lang-tick" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'
                  : '')
              . '</a>';
      }
      echo json_encode(
          '<button type="button" class="pnav-link pnav-dd-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="hc-lang-menu">'
            . hc_lang_flag_img($_locale)
            . '<span class="px-lang-code">' . hc_esc($__nlShort) . '</span>'
            . '<span class="pnav-ind" aria-hidden="true"></span>'
          . '</button><div class="pnav-dd-menu" id="hc-lang-menu" role="menu">' . $__nlRows . '</div>',
          JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
  ?>;
  /* The nav opens its panels on hover; touch has no hover, so mirror the nav's own click
     toggle. Placed before the account chip so the order reads brand | links | lang | account,
     the same order the portal renders. */
  var langButton=box.querySelector('.pnav-dd-btn');
  langButton.addEventListener('click', function(e){
    e.stopPropagation();var open=!box.classList.contains('pnav-dd-open');box.classList.toggle('pnav-dd-open',open);langButton.setAttribute('aria-expanded',open?'true':'false');
  });
  box.addEventListener('keydown',function(e){if(e.key==='Escape'){e.preventDefault();box.classList.remove('pnav-dd-open');langButton.setAttribute('aria-expanded','false');langButton.focus();}else if(e.key==='ArrowDown'){var first=box.querySelector('[role="menuitem"]');if(first){e.preventDefault();first.focus();}}});
  document.addEventListener('click',function(e){if(!box.contains(e.target)){box.classList.remove('pnav-dd-open');langButton.setAttribute('aria-expanded','false');}});
  /* Same end slot the account chip uses, so .pnav-in keeps three children and its
     space-between layout does not re-spread around a fourth. */
  var end = host.querySelector('.px-navend');
  if (!end){ end = document.createElement('div'); end.className = 'px-navend'; host.appendChild(end); }
  var acct = document.getElementById('px-acct');
  if (acct && acct.parentNode !== end) end.appendChild(acct);
  if (acct) end.insertBefore(box, acct); else end.appendChild(box);
  /* Indicator masks (ni-/nia-/nip-) and --ni-size live on .pnav-items; outside that list the
     chevron loses its mask and draws as a grey square. Font and vertical padding are copied
     off a real link so the trigger cannot end up bigger or higher than "Home" beside it. */
  var items = document.querySelector('#px-nav .pnav-items');
  if (items){
    String(items.className).split(/\s+/).forEach(function(c){ if (/^(ni|nia|nip)-/.test(c)) end.classList.add(c); });
    var sz = items.style.getPropertyValue('--ni-size') || getComputedStyle(items).getPropertyValue('--ni-size');
    if (sz && sz.trim()) end.style.setProperty('--ni-size', sz.trim());
  }
  var ref = document.querySelector("#px-nav .pnav-items .pnav-link"), b2 = box.querySelector(".pnav-dd-btn");
  if (ref && b2){
    /* A closed mobile drawer still gives its links geometry. The old code copied those
       hidden drawer metrics and translated the language trigger down into the hero. Only
       borrow typography and alignment when the reference link is visibly in the nav row. */
    var alignLang = function(){
      box.style.transform = "";
      if (window.matchMedia && window.matchMedia("(max-width:760px)").matches){
        ["fontSize","fontWeight","letterSpacing","paddingTop","paddingBottom","lineHeight"].forEach(function(k){ b2.style[k]=""; });
        var mc=b2.querySelector(".px-lang-code"); if(mc) mc.style.fontSize="";
        return;
      }
      var itemsNow=document.querySelector("#px-nav .pnav-items"),navNow=document.querySelector("#px-nav .pnav");
      if(!itemsNow||!navNow||getComputedStyle(itemsNow).position==="absolute"||parseFloat(getComputedStyle(itemsNow).opacity||"1")<.99) return;
      var lr=ref.getBoundingClientRect(),nr=navNow.getBoundingClientRect();
      if(!lr.height||lr.top<nr.top-1||lr.bottom>nr.bottom+1) return;
      var r=getComputedStyle(ref);
      b2.style.fontSize=r.fontSize;b2.style.fontWeight=r.fontWeight;b2.style.letterSpacing=r.letterSpacing;
      b2.style.paddingTop=r.paddingTop;b2.style.paddingBottom=r.paddingBottom;b2.style.lineHeight=r.lineHeight;
      var cd=b2.querySelector(".px-lang-code"),px=parseFloat(r.fontSize);
      if(cd&&px) cd.style.fontSize=Math.max(10.5,px-1.5)+"px";
      var br=b2.getBoundingClientRect(),d=((lr.top+lr.bottom)/2)-((br.top+br.bottom)/2);
      if(Math.abs(d)>.5) box.style.transform="translateY("+d.toFixed(2)+"px)";
    };
    requestAnimationFrame(alignLang);
    window.addEventListener("resize",alignLang);
  }
})();</script>
<?php endif; ?>
<?php /* PHASE_PORTAL_NAV_SECTION — the shared nav_html now renders the brand as a LINK to
        the portal landing page ("/"), and may already carry the portal's own section chip.
        On THIS surface the brand must go to the help center home instead, and the chip must
        read the help center's label — not the portal's. So: repoint the href, and reuse the
        engine's chip if it is there rather than appending a second one. */ ?>
<script>(function(){
  var b=document.querySelector('#px-nav .pnav-brand');
  if(!b) return;
  if(b.tagName==='A') b.setAttribute('href', <?= json_encode(hc_u(''), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
<?php if ($__helpNavLabel !== ''): ?>
  var txt=<?= json_encode($__helpNavLabel, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var existing=b.querySelector('.pnav-label');
  if(existing){ existing.textContent=txt; }                 /* portal's chip → our label */
  else if(!b.querySelector('.pnav-hc-label')){
    var s=document.createElement('span'); s.className='pnav-hc-sep';
    var l=document.createElement('span'); l.className='pnav-hc-label'; l.textContent=txt;
    b.appendChild(s); b.appendChild(l);
  }
<?php else: ?>
  var lbl=b.querySelector('.pnav-label'), sep=b.querySelector('.pnav-sep');
  if(lbl) lbl.remove(); if(sep) sep.remove();               /* label cleared → hide the chip here too */
<?php endif; ?>
})();</script>
<?php endif; ?>
<?php /* Match the CONTENT (page bg, cards, sidebar, article prose) to the portal
        palette across ALL 20 help layouts — including the dark ones whose
        high-specificity !important rules beat any stylesheet override. Inline
        styles + real contrast math: every text element is checked against its
        ACTUAL painted backdrop and re-inked only when unreadable, so dark code
        blocks keep light text while dark-theme prose flips to portal ink.
        The hero and .hc-subhero keep their own designs (accent-tinted via the
        blend overlay above), so they are skipped here. */ ?>
<script>(function(){
  var CARD='.hc-kb-sidebar,.hc-sidebar,.hc-toc,.hc-kb-cat-link,.hc-pop-item,.hc-cat,.hc-art-card,.hc-cat-card,.hc-article-card,.hc-side-card,.hc-toc-card,.hc-rel,.hc-rel-list,.hc-arow,.hc-empty,.hc-list-item,.hc-arow-item,.hc-result,.hc-search-result,.hc-cat-tile,.hc-kb-more,.hc-more,.hc-show-more,.hc-load-more,.hc-btn-more';
  /* PHASE10K9_2026-08-11 — #hc-portal-hero JOINS THE SKIP LIST.
   *
   * This routine exists to make Help Center components read like the Portal.
   * The Portal hero is not a Help Center component — it IS the Portal's own
   * markup, rendered by the Portal's own function. Matching it to the Help
   * Center was backwards, and it is what made the heading render in the Help
   * Center's face: the loop wrote font-family inline with !important, which
   * outranks every stylesheet, so the Portal's Raleway lost to Proxima Nova and
   * the same 62px heading wrapped onto a second line. Skipping the subtree
   * leaves the Portal hero exactly as the Portal draws it. */
  var SKIP='#hc-hero,#hc-portal-hero,.hc-portal-hero,.hc-subhero,.hc-subhero-portal,.psubhero,#px-nav,#px-footer,#px-announce,#hc-hdr,.hc-hdr,.pnav,.pfoot,.hc-foot,pre,code,.hc-code,.hc-suggest';
  var probe=document.createElement('span');probe.style.display='none';document.documentElement.appendChild(probe);
  function toRgb(c){probe.style.color='#000';probe.style.color=c;var m=getComputedStyle(probe).color.match(/[\d.]+/g);return m?m.slice(0,3).map(Number):[0,0,0]}
  function lum(m){var a=m.map(function(v){v/=255;return v<=.03928?v/12.92:Math.pow((v+.055)/1.055,2.4)});return .2126*a[0]+.7152*a[1]+.0722*a[2]}
  function contrast(a,b){var x=lum(a),y=lum(b),hi=Math.max(x,y),lo=Math.min(x,y);return (hi+.05)/(lo+.05)}
  function sat(m){var mx=Math.max(m[0],m[1],m[2]),mn=Math.min(m[0],m[1],m[2]);return mx?(mx-mn)/mx:0}
  function backOf(el,fallback){var e=el;while(e&&e!==document.documentElement){var b=getComputedStyle(e).backgroundColor,m=(b||'').match(/[\d.]+/g);if(m){var a=m.length>3?parseFloat(m[3]):1;if(a>.4)return m.slice(0,3).map(Number);}e=e.parentElement;}return fallback}
  function own(el,prop,value){
    if(!el)return;
    el.style.setProperty(prop,value,'important');
    var list=(el.getAttribute('data-opsiq-palette-owned')||'').split(',').filter(Boolean);
    if(list.indexOf(prop)<0){list.push(prop);el.setAttribute('data-opsiq-palette-owned',list.join(','));}
  }
  function releaseOwned(){
    document.querySelectorAll('[data-opsiq-palette-owned]').forEach(function(el){
      (el.getAttribute('data-opsiq-palette-owned')||'').split(',').filter(Boolean).forEach(function(prop){el.style.removeProperty(prop);});
      el.removeAttribute('data-opsiq-palette-owned');
    });
  }
  function paint(){
    /* Re-evaluate from the stylesheet on every theme transition. In particular,
       -webkit-text-fill-color from a dark gradient title must not survive when
       the light stylesheet no longer reports background-clip:text. */
    releaseOwned();
    var rs=getComputedStyle(document.documentElement);
    var C=rs.getPropertyValue('--card').trim()||'#ffffff', I=rs.getPropertyValue('--ink').trim()||'#0f172a',
        L=rs.getPropertyValue('--line').trim()||'#e2e8f0', A=rs.getPropertyValue('--accent').trim()||'#6c5ce7',
        BG=rs.getPropertyValue('--bg').trim()||'#f6f7fb';
    var Br=toRgb(BG);
    var page=document.getElementById('hc-page');
    /* PHASE10K10_2026-08-11 — ONLY THE PAGE IS PAINTED. The content wrappers used
     * to be filled with the portal background too, which was invisible while they
     * were full width and became a flat slab behind the cards the moment the
     * column took a container width — with a visible edge under the last row, and
     * worst on this very surface, where the page colour is the portal's. They are
     * CLEARED here rather than skipped: an earlier run of this same routine may
     * have written the fill inline, and an inline !important outlives any
     * stylesheet fix. */
    /* THE PORTAL'S PAGE BACKGROUND, WHEN IT HAS ONE. --px-page-bg carries the operator's
       chosen surface (solid or gradient) exactly as the portal paints it; --bg is only the
       flat token underneath. Painting background-color + background-image:none here erased
       every gradient and every non-token colour the operator picked, so "matching" matched
       the theme but not the background. Body carries the real surface and the page shell
       goes transparent, so a gradient runs the height of the page instead of restarting
       inside #hc-page's box. */
    var PBG=rs.getPropertyValue('--px-page-bg').trim();
    var bgEls=[document.body];if(page)bgEls.push(page);
    if(PBG){
      own(document.body,'background',PBG);
      if(page){own(page,'background','transparent');}
    }else{
      bgEls.forEach(function(e){if(!e||(e.closest&&e.closest('#hc-hero,.hc-subhero')))return;own(e,'background-color',BG);own(e,'background-image','none');});
    }
    [].slice.call(document.querySelectorAll('.hc-main,.hc-app,.hc-shell,.hc-content')).forEach(function(e){
      own(e,'background-color','transparent');
      own(e,'background-image','none');
    });
    /* COLOUR-ONLY card matching: each layout keeps its own borders, radii,
     * shadows and spacing — we only swap a background that is dark or heavily
     * tinted (off the portal palette) for the portal card colour, and only
     * recolour a border that is dark/tinted. Never add borders, never strip
     * shadows: the layout's design stays, the palette matches. */
    function offPalette(m,aMin){if(!m)return false;var a=m.length>3?m[3]:1;if(a<(aMin||.4))return false;return lum(m)<.5||sat(m)>.3}
    function parseCol(c){var m=(c||'').match(/[\d.]+/g);return m?m.map(Number):null}
    document.querySelectorAll(CARD).forEach(function(e){
      if(e.closest(SKIP))return;
      var cs=getComputedStyle(e);
      if(offPalette(parseCol(cs.backgroundColor))){
        own(e,'background-color',C);
        if(cs.backgroundImage!=='none')own(e,'background-image','none');
      }
      if(parseFloat(cs.borderTopWidth)>0&&offPalette(parseCol(cs.borderTopColor),.25)){
        own(e,'border-color',L);
      }
    });
    if(!page)return;
    page.querySelectorAll('input,select,textarea').forEach(function(e){
      if(e.closest(SKIP))return;
      var cs=getComputedStyle(e);
      if(offPalette(parseCol(cs.backgroundColor))){own(e,'background-color',C);own(e,'color',I);}
      if(parseFloat(cs.borderTopWidth)>0&&offPalette(parseCol(cs.borderTopColor),.25))own(e,'border-color',L);
    });
    /* PHASE10K9c_2026-08-11 — PORTAL MATCHING DOES NOT RESTYLE HELP CENTER TEXT.
     *
     * The owner's rule, in their words: "portal only match hero and colour, nav,
     * footer etc. it doesn't change anything else from /hc."
     *
     * This block used to normalise the Help Center's own type — article titles
     * to 30px/800, card titles to 15px/700, sidebar links with them, body to
     * 15px/1.6 — and then rewrite font-family on every text node, all inline
     * with !important. That is why the Help Center's own typography settings
     * appeared to do nothing on a portal-matched surface, and why the sidebar
     * links rendered larger and heavier than their own heading. Matching is now
     * what the name says: the hero, the palette, and the shared chrome. The
     * Help Center's text stays the Help Center's, configured in Text & Labels.
     *
     * PF is still resolved below for the contrast pass, which needs no font. */
    /* Light mode already owns a complete Help Center typography palette. Once
     * dark-owned overrides are released, leave that configured title/subtitle
     * colour exactly as a fresh light render would. */
    if(document.documentElement.getAttribute('data-theme')!=='dark')return;
    page.querySelectorAll('*').forEach(function(el){
      if(el.closest(SKIP))return;
      var t=false;for(var n=el.firstChild;n;n=n.nextSibling){if(n.nodeType===3&&/\S/.test(n.nodeValue)){t=true;break}}
      if(!t)return;
      var cs=getComputedStyle(el);
      /* COLOURS ONLY. The font-family rewrite that used to sit here made the
       * whole Help Center wear the Portal's face; matching is the palette, the
       * hero and the shared chrome, and nothing else. */
      if((cs.webkitBackgroundClip||cs.backgroundClip||'').indexOf('text')>-1){
        own(el,'background','none');
        own(el,'-webkit-text-fill-color',I);
        own(el,'color',I);
        return;
      }
      var col=toRgb(cs.color);
      if(contrast(col,backOf(el,Br))<3){own(el,'color',sat(col)>.5?A:I);}
    });
  }
  /* Initial render races the SPA boot/other inline scripts, so besides the
   * mutation observer run a few settled passes — inline styles are idempotent. */
  paint();
  try{requestAnimationFrame(paint);}catch(e){}
  setTimeout(paint,350);setTimeout(paint,1200);
  window.addEventListener('load',paint);
  /* The palette matcher writes the page canvas inline with !important, so CSS
   * cannot release that colour when data-theme changes. Repaint on both the
   * public theme event and the root attribute itself (the latter also covers
   * OS-auto and cross-document storage synchronisation, which call apply()
   * without dispatching the public event). */
  window.addEventListener('opsiq-help-theme',function(){try{requestAnimationFrame(paint);}catch(e){paint();}});
  try{var tm=new MutationObserver(function(m){for(var i=0;i<m.length;i++){if(m[i].attributeName==='data-theme'){paint();break}}});tm.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});}catch(e){}
  try{var mo=new MutationObserver(function(){paint();});mo.observe(document.body,{childList:true,subtree:true});}catch(e){}
})();</script>
<?php endif; ?>

<?php if (!$_embed): ?>
<div id="hc-prog" aria-hidden="true"></div>
<div id="hc-route-loader" class="hc-route-loader" aria-live="polite" aria-hidden="true"><div class="hc-route-loader-card"><span class="hc-route-spinner" aria-hidden="true"></span><span><?= hc_esc($__t("loading_help_center", "Loading help center")) ?></span></div></div>
<div id="hc-toast" class="hc-toast" aria-live="polite"></div>
<?php endif; ?>

<!-- Sticky header -->
<?php if (!$_embed && $_showHeaderNav && !$__useUnifiedNav): ?>
<?= hc_render_announce_bar() ?>
<?= hc_render_nav() ?>
<?php endif; ?>

<!-- AJAX page container -->
<?= $_viewHtmlForPage ?>

<?php if ($__useUnifiedFooter): ?>
<div id="px-footer"><?= $__portalDesignBundle['footer_html'] ?></div>
<script>(function(){var cols=document.querySelectorAll('#px-footer .pfoot-col');if(!cols.length)return;function sync(){var mobile=window.matchMedia('(max-width:640px)').matches;cols.forEach(function(col){var b=col.querySelector('.pfoot-col-t');if(b)b.setAttribute('aria-expanded',mobile?(col.classList.contains('pf-open')?'true':'false'):'true');});}cols.forEach(function(col){var b=col.querySelector('.pfoot-col-t');if(!b)return;b.addEventListener('click',function(){if(window.matchMedia('(max-width:640px)').matches){col.classList.toggle('pf-open');sync();}});b.addEventListener('keydown',function(e){if(e.key==='ArrowDown'&&window.matchMedia('(max-width:640px)').matches){e.preventDefault();col.classList.add('pf-open');sync();var a=col.querySelector('.pfoot-links a');if(a)a.focus();}});});window.addEventListener('resize',sync);sync();})();</script>
<?php elseif (trim((string)($GLOBALS['_settings']['footer_custom_html'] ?? '')) !== ''): ?>
<?php /* PHASE10K3 — the operator's own footer replaces the built one; same
        variable substitution as the custom nav. */ ?>
<footer class="hc-foot hc-foot-custom<?= hc_chrome_autodark_class((string)$GLOBALS['_settings']['footer_custom_html']) ?>" id="hc-foot"><?= hc_custom_chrome((string)$GLOBALS['_settings']['footer_custom_html']) ?></footer>
<?php elseif ($showFooter): ?>
<footer class="hc-foot<?= $footerColumns ? ' hc-foot-rich' : '' ?>" id="hc-foot">
  <?php
  /* PHASE_HC_FOOTER_BRAND — the band renders when there are columns OR a brand block,
     so a brand-only footer is still possible. */
  /* $logoUrl is ALREADY htmlspecialchars'd at the top of the file, so falling back to
     it here and running hc_esc() again would double-escape an & in a query string.
     Take the raw setting instead and let the one hc_esc() below do the escaping. */
  $__brandLogoUrl = $footerBrandLogo !== '' ? $footerBrandLogo : trim((string)($_settings['logo_url'] ?? ''));
  $__brandName    = $footerBrandTitle !== '' ? $footerBrandTitle : $siteName;
  $__hasBrand     = $footerBrandEnabled && ($__brandLogoUrl !== '' || $__brandName !== '' || $footerBrandText !== '');
  ?>
  <?php if ($footerColumns || $__hasBrand): /* PHASE_HC_MORE_CONFIG — link columns band (its own colour) */ ?>
  <div class="hc-foot-band hc-foot-links-band">
    <div class="hc-foot-band-inner">
    <div class="hc-foot-top<?= $__hasBrand ? ' hc-foot-has-brand hc-foot-brand-' . hc_esc($footerBrandPosition) : '' ?>">
    <?php if ($__hasBrand): ?>
      <div class="hc-foot-brand">
        <?php if ($__brandLogoUrl !== ''): ?>
        <img class="hc-foot-brand-logo" src="<?= hc_esc($__brandLogoUrl) ?>" alt="<?= hc_esc($__brandName !== '' ? $__brandName : $siteName) ?>" loading="lazy" decoding="async">
        <?php elseif ($__brandName !== ''): ?>
        <div class="hc-foot-brand-name"><?= hc_esc($__brandName) ?></div>
        <?php endif; ?>
        <?php if ($footerBrandText !== ''): ?>
        <p class="hc-foot-brand-text"><?= hc_esc($footerBrandText) ?></p>
        <?php endif; ?>
        <?php if ($footerBrandAddress !== '' || $footerBrandPhone !== ''): ?>
        <div class="hc-foot-brand-contact">
          <?php if ($footerBrandAddress !== ''): ?>
          <div class="hc-foot-brand-cline">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>
            <span><?= nl2br(hc_esc($footerBrandAddress)) ?></span>
          </div>
          <?php endif; ?>
          <?php if ($footerBrandPhone !== ''): ?>
          <div class="hc-foot-brand-cline">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 1.9.6 2.8a2 2 0 0 1-.5 2.1L8.1 9.7a16 16 0 0 0 6 6l1.1-1.1a2 2 0 0 1 2.1-.5c.9.3 1.8.5 2.8.6a2 2 0 0 1 1.9 2.2z"/></svg>
            <?php if ($footerBrandPhoneHref !== ''): ?>
            <a href="tel:<?= hc_esc($footerBrandPhoneHref) ?>"><?= hc_esc($footerBrandPhone) ?></a>
            <?php else: ?>
            <span><?= hc_esc($footerBrandPhone) ?></span>
            <?php endif; ?>
          </div>
          <?php endif; ?>
        </div>
        <?php endif; ?>
        <?php if ($footerSocialPosition === 'brand') echo hc_social_row($footerSocial, 'hc-foot-social-brand'); ?>
        <?php if ($footerPaymentPosition === 'brand') echo hc_payment_row($footerPayment, $footerPaymentNote, 'hc-foot-pay-brand'); ?>
      </div>
    <?php endif; ?>
    <div class="hc-foot-cols">
      <?php foreach ($footerColumns as $__col): ?>
      <div class="hc-foot-col">
        <?php if ($__col['title'] !== ''): ?>
          <?php if ($footerMobileAccordion && $__col['links']): /* button only so a phone can collapse it; CSS keeps it a plain heading on desktop */ ?>
          <button type="button" class="hc-foot-col-title hc-foot-acc" aria-expanded="false"><span><?= hc_esc($__col['title']) ?></span><i class="hc-foot-acc-ico" aria-hidden="true"></i></button>
          <?php else: ?>
          <div class="hc-foot-col-title"><?= hc_esc($__col['title']) ?></div>
          <?php endif; ?>
        <?php endif; ?>
        <?php if ($__col['links']): ?>
        <ul class="hc-foot-col-links">
          <?php foreach ($__col['links'] as $__lk): ?>
          <li><a href="<?= hc_esc($__lk['url']) ?>"<?= preg_match('~^https?://~i', $__lk['url']) ? ' target="_blank" rel="noopener noreferrer"' : '' ?>><?= hc_esc($__lk['label']) ?></a></li>
          <?php endforeach; ?>
        </ul>
        <?php endif; ?>
      </div>
      <?php endforeach; ?>
    </div>
    </div>
    </div>
  </div>
  <?php endif; /* PHASE_HC_DESIGN_STUDIO — copyright band (separately colourable from the links) */ ?>
  <div class="hc-foot-band hc-foot-copy-band">
    <div class="hc-foot-band-inner">
    <div class="hc-foot-bottom">
      <?php if ($footerText !== ''): ?>
      <p class="hc-foot-text"><?= $footerText ?></p>
      <?php else: ?>
      <p class="hc-foot-text">&copy; <?= $_currentYear ?> <?= hc_esc($siteName) ?></p>
      <?php endif; ?>
      <?php
      /* PHASE_HC_FOOTER_BRAND — legal links either stay inline beside the copyright
         or group to the right with the social icons. Grouping is the default: beside
         the copyright they read as a run-on of the same sentence. */
      $__legalHtml = '';
      if ($footerLegalLinks) {
          $__legalHtml = "<nav class=\"hc-foot-legal\" aria-label=\"" . hc_esc($__t("legal", "Legal")) . "\">";
          foreach ($footerLegalLinks as $__lk) {
              $__legalHtml .= '<a href="' . hc_esc($__lk['url']) . '"'
                  . (preg_match('~^https?://~i', $__lk['url']) ? ' target="_blank" rel="noopener noreferrer"' : '')
                  . '>' . hc_esc($__lk['label']) . '</a>';
          }
          $__legalHtml .= '</nav>';
      }
      $__socialHtml = $footerSocialPosition === 'copyright' ? hc_social_row($footerSocial, 'hc-foot-social-copy') : '';
      $__payCopyHtml = $footerPaymentPosition === 'copyright' ? hc_payment_row($footerPayment, '', 'hc-foot-pay-copy') : '';
      ?>
      <?php if ($footerLegalPosition === 'left') echo $__legalHtml; ?>
      <?php if ($__legalHtml !== '' || $__socialHtml !== '' || $__payCopyHtml !== ''): ?>
      <div class="hc-foot-bottom-end">
        <?= $__payCopyHtml ?>
        <?php if ($footerLegalPosition === 'right') echo $__legalHtml; ?>
        <?= $__socialHtml ?>
      </div>
      <?php endif; ?>
    </div>
    </div>
  </div>
</footer>
<?php endif; ?>

<?php if (!$_embed): ?>
<button id="hc-top" class="hc-totop-<?= hc_esc($backToTopPosition) ?>" aria-label="<?= hc_esc($__t("back_to_top", "Back to top")) ?>" title="<?= hc_esc($__t("back_to_top", "Back to top")) ?>">
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<?php endif; ?>

<script>
/* PHASE_HC_FOOTER_BRAND — phone accordion for the footer link columns. The footer
 * lives OUTSIDE #hc-page, so it survives the router's swap and binding once is
 * enough — but the guard keeps it safe if this ever moves inside. Above the
 * breakpoint the button is styled as a plain heading and the click is a no-op,
 * so there is nothing to tear down on resize. */
(function(){
  var foot = document.getElementById('hc-foot');
  if (!foot || foot.dataset.accBound === '1') return;
  foot.dataset.accBound = '1';
  var mq = window.matchMedia('(max-width:640px)');
  foot.addEventListener('click', function(e){
    var btn = e.target.closest ? e.target.closest('.hc-foot-acc') : null;
    if (!btn || !mq.matches) return;
    e.preventDefault();
    var col = btn.closest('.hc-foot-col');
    if (!col) return;
    var open = col.classList.toggle('is-open');
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
  });
  /* Widening back to desktop must not leave a column stuck collapsed — the CSS
     only hides the links below the breakpoint, but the class would linger. */
  var onChange = function(){
    if (mq.matches) return;
    foot.querySelectorAll('.hc-foot-col.is-open').forEach(function(c){
      c.classList.remove('is-open');
      var b = c.querySelector('.hc-foot-acc');
      if (b) b.setAttribute('aria-expanded','false');
    });
  };
  if (mq.addEventListener) mq.addEventListener('change', onChange);
  else if (mq.addListener) mq.addListener(onChange);
})();
/* PHASE_HC_MOBILE_NAV — hamburger toggle for the public header on mobile. */
(function(){
  var burger = document.getElementById('hc-nav-burger');
  var hdr = document.getElementById('hc-hdr');
  if (!burger || !hdr) return;
  burger.addEventListener('click', function(e){
    e.stopPropagation();
    var open = hdr.classList.toggle('hc-nav-open');
    burger.setAttribute('aria-expanded', open ? 'true' : 'false');
    /* PHASE_HC_JS_I18N_2026-08-15 — the server already sets this from `open_menu`; the
       toggle overwrote it with an English literal, so the guard asserting the literal is
       absent from help.php passed while the string was live. */
    burger.setAttribute('aria-label', open
      ? <?= json_encode((string)$__t('close_menu', 'Close menu'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
      : <?= json_encode((string)$__t('open_menu', 'Open menu'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
  });
  /* Dropdowns: click a header to open it (mobile accordion + desktop click-to-
     open). Only one open at a time. On desktop, hover still opens transiently;
     moving the mouse to another dropdown or blank space clears a click-pinned one
     so two menus never stay open together. */
  function hcCloseDrops(except){
    var open = hdr.querySelectorAll('.hc-nav-drop.is-open');
    for (var i=0;i<open.length;i++) if (open[i] !== except) open[i].classList.remove('is-open');
  }
  hdr.addEventListener('click', function(e){
    var btn = e.target.closest ? e.target.closest('button') : null;
    if (!btn || !btn.parentNode || !btn.parentNode.classList || !btn.parentNode.classList.contains('hc-nav-drop')) return;
    e.preventDefault(); e.stopPropagation();
    var drop = btn.parentNode, wasOpen = drop.classList.contains('is-open');
    hcCloseDrops(drop);
    drop.classList.toggle('is-open', !wasOpen);
  });
  hdr.addEventListener('mouseover', function(e){
    if (!window.matchMedia || !window.matchMedia('(min-width:821px)').matches) return; // desktop only
    var drop = e.target.closest ? e.target.closest('.hc-nav-drop') : null;
    hcCloseDrops(drop); // drop=null when over non-dropdown area → clears all pins
  });
  /* PHASE_HC_NAV_ITEMS_2026-08-14 — KEEP A MEGA PANEL ON THE PAGE, CENTRED.
     The panel opens centred under the item that opened it. When that would put an edge
     off screen it is NUDGED by the smallest amount that brings it back — never flipped to
     one side, which is what made the trigger sit at a corner of its own menu instead of
     in the middle of it (owner: "MEGA MENU OPEN DIRECTLY UNDER IT AND THE MENU IN THE
     CENTER OF THE MEGA MENU, NOW IT SHIFTS TO SIDE").

     Measured on OPEN as well as on load: the panel is display:none until then, so its
     real width does not exist before it opens, and it changes with the promo column, the
     link count and the viewport. The sidebar flies out sideways and is exempt. */
  function hcFitMegaMenu(drop){
    if (!drop) return;
    var menu = drop.querySelector('.hc-nav-menu'); if (!menu) return;
    if (hdr.classList.contains('hc-nav-sidebar') && window.matchMedia('(min-width:821px)').matches) return;
    drop.classList.remove('hc-mega-clamp');
    menu.style.setProperty('--hc-mega-shift', '0px');
    var vw = document.documentElement.clientWidth, PAD = 12;
    var r = menu.getBoundingClientRect();
    if (r.width > vw - PAD * 2) { drop.classList.add('hc-mega-clamp'); return; }
    var shift = 0;
    if (r.left < PAD)            shift = PAD - r.left;              /* pull it right */
    else if (r.right > vw - PAD) shift = (vw - PAD) - r.right;      /* pull it left  */
    if (shift) menu.style.setProperty('--hc-mega-shift', Math.round(shift) + 'px');
  }
  function hcFitMegaMenus(){
    var drops = hdr.querySelectorAll('.hc-drop-mega');
    for (var i = 0; i < drops.length; i++) hcFitMegaMenu(drops[i]);
  }
  /* an OPEN panel has a real width; a closed one has none, so it is measured as it opens */
  hdr.addEventListener('mouseover', function(e){
    var d = e.target.closest ? e.target.closest('.hc-drop-mega') : null;
    if (d) hcFitMegaMenu(d);
  });
  hdr.addEventListener('click', function(e){
    var b = e.target.closest ? e.target.closest('.hc-nav-drop > button') : null;
    if (b) setTimeout(function(){ hcFitMegaMenu(b.parentNode); }, 0);
  });
  window.addEventListener('resize', hcFitMegaMenus);
  /* Leaving the header entirely fires NO mouseover on it, so a click-pinned menu
     used to hang open over the page until you clicked somewhere. The open menu is a
     DOM descendant of the header even though it is painted below it, so this fires
     only once the pointer has left the header AND the menu — moving down into the
     menu does not close it. */
  hdr.addEventListener('mouseleave', function(){
    if (!window.matchMedia || !window.matchMedia('(min-width:821px)').matches) return; // desktop only
    hcCloseDrops(null);
  });
  document.addEventListener('click', function(e){
    if (!hdr.contains(e.target)) {
      hcCloseDrops(null);
      if (hdr.classList.contains('hc-nav-open')) { hdr.classList.remove('hc-nav-open'); burger.setAttribute('aria-expanded','false'); }
    }
  });
  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') { hcCloseDrops(null); hdr.classList.remove('hc-nav-open'); burger.setAttribute('aria-expanded','false'); } });

  /* PHASE_HC_ANNOUNCE_BAR_2026-08-14 — the announcements.
     Three small jobs, all of which the page must survive without: rotate advances,
     dismiss remembers, and a sticky band offsets the sticky header. */
  (function(){
    var bars = document.querySelectorAll('.hc-annbar');
    if (!bars.length) return;
    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    bars.forEach(function(bar){
      /* DISMISS — remembered by WHAT IT SAYS, not by position, so closing today's notice
         never hides the next thing the operator posts, and re-posting the same words does
         not come back for someone who already closed them. */
      var key = bar.getAttribute('data-hc-ann-dismiss');
      if (key) {
        var store = 'hc-ann-x-' + key;
        try { if (localStorage.getItem(store)) { bar.remove(); return; } } catch(e){}
        var x = bar.querySelector('[data-hc-ann-close]');
        if (x) x.addEventListener('click', function(){
          bar.remove();
          try { localStorage.setItem(store, '1'); } catch(e){}
          setStickyOffset();
        });
      }

      /* ROTATE — one at a time. The class swap happens whether or not motion is allowed;
         only the CSS transition is suppressed, so the announcements still advance for
         someone who asked for less movement. */
      var secs = parseInt(bar.getAttribute('data-hc-ann-rotate') || '0', 10);
      var items = bar.querySelectorAll('.hc-annbar-item');
      if (secs > 0 && items.length > 1) {
        var i = 0, timer = null;
        var go = function(){
          items[i].classList.remove('is-on');
          i = (i + 1) % items.length;
          items[i].classList.add('is-on');
        };
        var start = function(){ if (!timer) timer = setInterval(go, secs * 1000); };
        var stop  = function(){ if (timer) { clearInterval(timer); timer = null; } };
        start();
        /* someone reading it, or a tab nobody is looking at, should not be advanced */
        bar.addEventListener('mouseenter', stop);
        bar.addEventListener('mouseleave', start);
        bar.addEventListener('focusin', stop);
        bar.addEventListener('focusout', start);
        document.addEventListener('visibilitychange', function(){ document.hidden ? stop() : start(); });
      }
    });

    /* STICKY — the header is position:sticky at top:0. A sticky band would sit UNDER it,
       so the header is pushed down by the band's measured height. Measured, never
       assumed: the height changes with the size preset, the wrapping of a long line, and
       the viewport. With no script the band simply scrolls away, which is the default. */
    function setStickyOffset(){
      var stuck = document.querySelector('.hc-annbar-sticky');
      var h = stuck ? Math.round(stuck.getBoundingClientRect().height) : 0;
      document.documentElement.style.setProperty('--hc-annbar-h', h + 'px');
    }
    setStickyOffset();
    window.addEventListener('resize', setStickyOffset);
    if (window.ResizeObserver) {
      var st = document.querySelector('.hc-annbar-sticky');
      if (st) new ResizeObserver(setStickyOffset).observe(st);
    }
  })();
})();
</script>
<script>
(function(){
'use strict';

var HELP_BASE = <?= json_encode($helpBase) ?>;
var SITE_KEY  = <?= json_encode($_siteKey) ?>;
/* PHASE10K9_2026-08-11 — the search component's own strings. {q} is substituted
 * client-side, so this is a token and not a printf placeholder. */
var HC_SRCH_NO_MATCH = <?= json_encode($__t('srch_no_match', 'No matches for “{q}”')) ?>;
var HC_SRCH_HINT     = <?= json_encode($__t('srch_hint', 'Search')) ?>;
var IS_EMBED  = <?= $_embed ? 'true' : 'false' ?>;
var IS_FULL_EMBED = !!window.__opsiqHelpFullEmbedActive;
var HC_PAGE_LOADER = <?= (strtolower(trim((string)($_settings['page_loader'] ?? 'on'))) === 'off' ? 'false' : 'true') ?>;

/* ── PHASE_HC_ANALYTICS — the Help Center's OWN tracker ──────────────────────
 * Not the chat widget's beacon (a help center may run with no widget at all) and
 * nothing from the SaaS. ~40 lines, no dependencies, posts to this same page's
 * ?hca=1 endpoint. Emitted only when the workspace has switched analytics ON. */
var HCA_ON  = <?= (empty($GLOBALS['_isHcPreview']) && class_exists('\\OpsIQ\\Kb\\HcAnalytics') && \OpsIQ\Kb\HcAnalytics::enabled($_siteKey)) ? 'true' : 'false' ?>;
/* PHASE_HC_CONSENT_RACE_2026-08-17 — CONSENT WAS ON AND ANALYTICS OUTRAN IT.
 *
 * consentAllows() below reads window.OPSIQ_CONSENT, which is defined ONLY by widget.php.
 * On this workspace the widget is injected by the operator's custom_js, i.e.
 * ASYNCHRONOUSLY — so the beacon evaluated the gate at page load, saw `undefined`, took
 * the documented "no banner in play" branch and tracked unconditionally. Measured: the
 * workspace has cookie_consent_enabled=1 and the widget really does ship
 * OPSIQ_CONSENT={"enabled":true,…}, yet 8,805 sessions in two days were recorded with
 * full IPs and a durable visitor id, none of them gated.
 *
 * The gate itself is correct; only its INPUT arrives too late. The server already knows
 * the answer, so it is stated here instead of raced for — no timers, no buffering, no
 * change for a workspace that has consent switched off (the flag is simply false and the
 * old branch is taken verbatim). */
var HCA_CONSENT_EXPECTED = <?= (static function () use ($_siteKey): string {
    try {
        $v = \Illuminate\Database\Capsule\Manager::table('opsiq_settings')
            ->where('setting', 'site:' . $_siteKey . ':cookie_consent_enabled')->value('value');
        return in_array(strtolower(trim((string)$v)), ['1', 'on', 'true', 'yes'], true) ? 'true' : 'false';
    } catch (\Throwable $e) { return 'false'; }   /* unknown → behave exactly as before */
})() ?>;   /* PHASE7 — a preview writes no analytics */
var HCA_CTX = <?= json_encode([
    'article'  => $_articleId > 0 ? (int)$_articleId : null,
    'category' => !empty($_category['id']) ? (int)$_category['id'] : null,
    'query'    => $_query !== '' ? $_query : null,
    'results'  => $_query !== '' ? count($_articles ?? []) : null,
    'view'     => $_articleId > 0 ? 'view' : ($_query !== '' ? 'search' : (!empty($_category['id']) ? 'category' : 'home')),
    /* PHASE_HC_I18N_ANALYTICS — the language THIS page is displayed in (not the
     * browser's navigator.language), so analytics can answer "which languages do
     * people actually read in". */
    'locale'   => (string)$_locale,
]) ?>;

(function(){
  if (!HCA_ON) return;
  var K = 'opsiq_hc_v_' + SITE_KEY, S = 'opsiq_hc_s_' + SITE_KEY;
  function rid(p){ return p + Math.random().toString(36).slice(2) + Date.now().toString(36); }

  /* AUDIT PRIV-001 — honour the workspace's own cookie-consent decision.
   *
   * Help Center analytics writes a durable visitor id into localStorage, which
   * is itself the act ePrivacy/PECR regulates (it is technology-neutral: storing
   * information on a visitor's device needs consent unless it is strictly
   * necessary to deliver what they asked for, and audience measurement is not).
   * Previously this ran the moment analytics was enabled, with no reference to
   * the consent banner the same product already ships.
   *
   * The gate is deliberately CONDITIONAL, not fail-closed:
   *   • No consent runtime, or the workspace has the banner switched OFF
   *     (window.OPSIQ_CONSENT missing / .enabled false) → behave exactly as
   *     before. The operator has decided not to run a banner; that is their
   *     call and OpsIQ must not silently disable a feature they turned on.
   *   • Banner IS enabled → analytics only runs once the visitor has granted
   *     the 'analytics' category. Banner shown but no choice yet, or the
   *     cookie is unreadable, means no tracking.
   *
   * The consent runtime records per-category state in the opsiq_consent_cats
   * cookie (see templates/widget_consent.js.php). It exposes no JS event, so
   * consent is re-checked at flush time: a visitor who accepts mid-session
   * simply starts being measured from that point, and nothing collected before
   * the decision is ever sent. */
  function consentAllows(){
    /* The visitor's OWN RECORDED DECISION first. The cookie is written by the consent
     * runtime but readable without it, so a returning visitor is honoured immediately
     * and never depends on the widget having finished loading. */
    try {
      var m = document.cookie.match(/(?:^|; )opsiq_consent_cats=([^;]+)/);
      if (m) {
        var o = JSON.parse(decodeURIComponent(m[1]));
        return !!(o && o.analytics);
      }
    } catch(e) { return false; }                  // unreadable → do not track

    var P = window.OPSIQ_CONSENT;
    if (P && P.enabled) return false;             // banner up, visitor has not chosen yet
    /* No runtime YET. If the workspace has consent switched on, the runtime is still
     * loading and "absent" must not be read as "no banner" — that is the race this
     * flag exists to settle. Hold until the visitor has actually decided. */
    if (HCA_CONSENT_EXPECTED) return false;
    return true;                                  // consent genuinely off → unchanged
  }

  /* IDs are created LAZILY: nothing is written to the visitor's device until we
   * are actually allowed to measure. */
  var vid = '', sid = '';
  function ensureIds(){
    if (vid && sid) return;
    try {
      vid = localStorage.getItem(K)   || (localStorage.setItem(K, rid('v_')), localStorage.getItem(K));
      sid = sessionStorage.getItem(S) || (sessionStorage.setItem(S, rid('s_')), sessionStorage.getItem(S));
    } catch(e) { vid = rid('v_'); sid = rid('s_'); }   // private mode — still measurable, just not durable
  }

  var queue = [], maxScroll = 0, clicks = 0;
  function depth(){
    var d = document.documentElement,
        h = Math.max(1, (d.scrollHeight || 0) - (window.innerHeight || 0));
    return Math.max(0, Math.min(100, Math.round(((window.scrollY || d.scrollTop || 0) / h) * 100)));
  }
  addEventListener('scroll', function(){ maxScroll = Math.max(maxScroll, depth()); }, {passive:true});
  document.addEventListener('click', function(){ clicks++; }, {passive:true});

  function push(ev){ queue.push(ev); }
  function flush(keepalive){
    if (!queue.length) return;
    /* AUDIT PRIV-001 — no device storage and no transmission without consent.
     * Queued events are DISCARDED rather than held, so a visitor who accepts
     * later is never retro-tracked for what they did before deciding. */
    if (!consentAllows()) { queue.length = 0; return; }
    ensureIds();
    var body = JSON.stringify({
      vid: vid, sid: sid, url: location.href, ref: document.referrer || '',
      lang: navigator.language || '', hc_locale: HCA_CTX.locale || '',
      vw: window.innerWidth || 0, vh: window.innerHeight || 0,
      events: queue.splice(0, queue.length)
    });
    var url = HELP_BASE + (HELP_BASE.indexOf('?') === -1 ? '?' : '&') + 'hca=1';
    // sendBeacon survives the page unload; fetch is the fallback while browsing.
    if (keepalive && navigator.sendBeacon) {
      try { navigator.sendBeacon(url, new Blob([body], {type:'application/json'})); return; } catch(e){}
    }
    fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:body, keepalive:!!keepalive}).catch(function(){});
  }

  // What this page IS — one event, on arrival.
  push({
    event: HCA_CTX.view, article_id: HCA_CTX.article, category_id: HCA_CTX.category,
    query: HCA_CTX.query, results_count: HCA_CTX.results, zero_results: HCA_CTX.results === 0
  });
  flush(false);

  // Reader gave up and went for a human — the deflection FAILURE signal.
  document.addEventListener('click', function(e){
    var a = e.target.closest && e.target.closest('a'); if (!a) return;
    var href = a.getAttribute('href') || '';
    if (/contact|ticket|support|mailto:/i.test(href) && a.closest('.hc-contact, .hc-foot, .hc-body, .hc-feedback')) {
      push({event:'contact', article_id: HCA_CTX.article, target: href.slice(0,300)});
      flush(true);
    } else if (HCA_CTX.article && a.closest('.hc-body')) {
      // a link or an attachment INSIDE the article body
      var dl = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|png|jpe?g)$/i.test(href);
      push({event: dl ? 'attachment' : 'link_click', article_id: HCA_CTX.article, target: href.slice(0,300)});
    }
  }, true);

  /* How far they actually got — the "where do people lose interest" data.
   *
   * Time is counted ONLY while the page is visible, and nothing is reported until
   * the page has actually been SEEN. That matters: a page opened in a background
   * tab (middle-click, "open in new tab") starts hidden, so a naive
   * visibilitychange handler fires immediately with 0 scroll and ~0 seconds, and
   * a one-shot guard then blocks the REAL reading session from ever being
   * recorded. Reading metrics would be permanently poisoned by the most ordinary
   * browsing habit there is.
   *
   * Each report carries the time since the LAST report (a delta), so switching
   * tabs mid-read neither loses time nor double-counts it; scroll depth is the
   * running maximum. */
  var everVisible = (document.visibilityState === 'visible');
  var shownAt     = everVisible ? Date.now() : 0;
  var visibleMs   = 0;
  var reportedMs  = 0;

  function activeMs(){ return visibleMs + (shownAt ? (Date.now() - shownAt) : 0); }

  function engage(keepalive){
    if (!everVisible) return;                    // never actually seen — nothing happened
    var ms = activeMs(), delta = ms - reportedMs;
    if (delta < 1000 && reportedMs > 0) return;  // nothing new worth reporting
    reportedMs = ms;
    var secs = Math.round(delta / 1000);
    push({
      event: 'engage', article_id: HCA_CTX.article, category_id: HCA_CTX.category,
      time_spent: secs, scroll_depth: maxScroll, clicks: clicks,
      completed: (HCA_CTX.article ? (maxScroll >= 85 && Math.round(ms / 1000) >= 15) : false)
    });
    flush(keepalive);
  }

  document.addEventListener('visibilitychange', function(){
    if (document.visibilityState === 'visible') {
      everVisible = true;
      if (!shownAt) shownAt = Date.now();        // resume the clock
    } else {
      if (shownAt) { visibleMs += Date.now() - shownAt; shownAt = 0; }   // pause it
      engage(true);
    }
  });
  addEventListener('pagehide', function(){
    if (shownAt) { visibleMs += Date.now() - shownAt; shownAt = 0; }
    engage(true);
  });
})();

/* Utilities */
function qs(sel,ctx){ return (ctx||document).querySelector(sel); }
function qsa(sel,ctx){ return Array.prototype.slice.call((ctx||document).querySelectorAll(sel)); }
function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

/* Scroll progress + back-to-top */
var prog   = qs('#hc-prog');
var topBtn = qs('#hc-top');
var routeLoader = qs('#hc-route-loader');
var toast = qs('#hc-toast');

/* PHASE_HC_CATEGORY_LIMIT — "Show all N categories". The link is a real
 * <a href="?cats=all">, so with JS off it just loads the full page. With JS on
 * we expand the grid in place: the hidden tiles are already in the DOM.
 * PHASE0_2026-08-06 — was a boot-once IIFE binding DOM inside #hc-page, so the
 * router's outerHTML swap orphaned it and every soft nav back home lost the
 * in-place expand (audit item 4.6). Now a re-bootable function called from
 * hcInitView(), idempotent via dataset flag like the portal-hero handler. */
function hcInitCatsMore(){
  var more = qs('.hc-cats-more[data-hc-more]');
  if (!more || more.dataset.moreBound === '1') return;
  more.dataset.moreBound = '1';
  more.addEventListener('click', function(e){
    /* PHASE0.5_2026-08-09 — the eight non-tile layouts render into .hc-dir, not
     * .hc-home-cats. This only ever looked up the tiles grid, so on a directory the
     * control fell through to the href and reloaded the page instead of expanding in
     * place — and before the limit was wired in at all, it revealed nothing either. */
    /* PHASE_HC_CATS_PAGINATE_2026-08-14 — EVERY grid in the section, not the first.
       A section can carry up to three presentations, so expanding only the first left
       the rest of the categories hidden behind their own hide-point. Same defect the
       paginator had. */
    /* PHASE_HC_SUBCAT_MORE_2026-08-24 — `.hc-subcats` is the same kind of container
       on a category page. It is named rather than given the home shell's class,
       because that class carries margins of its own (hc-sheet-3.css:79) and
       borrowing it would quietly respace every subcategory list. */
    var shell = (more.closest && more.closest('.hc-home-cats-shell, .hc-subcats')) || document;
    var grids = [].slice.call(shell.querySelectorAll('.hc-home-cats, .hc-dir'));
    if (!grids.length) { var g1 = qs('.hc-home-cats') || qs('.hc-dir'); if (g1) grids = [g1]; }
    if (!grids.length) return;       // fall through to the href
    e.preventDefault();
    /* PHASE10H_2026-08-11 — stop the router's document-level listener too; it
     * used to fetch and swap to the browse page right after this expand ran. */
    e.stopPropagation();
    /* Keep the reader EXACTLY where they clicked. The earlier focus() (even with
     * preventScroll) yanked the page to the first revealed tile in some browsers,
     * so we don't focus at all — instead we snapshot the scroll position, reveal
     * the tiles, then hard-restore the scroll. This is bulletproof regardless of
     * which element actually scrolls (window OR the documentElement/body). */
    var se  = document.scrollingElement || document.documentElement;
    var yWin = window.pageYOffset || 0;
    var ySE  = se ? se.scrollTop : 0;
    grids.forEach(function(g){ g.classList.add('hc-cats-expanded'); });
    var wrap = more.parentNode;
    if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap);
    // Restore now and again on the next frame (covers post-layout scroll shifts).
    var restore = function(){ try { window.scrollTo(0, yWin); if (se) se.scrollTop = ySE; } catch(_e){} };
    restore();
    if (window.requestAnimationFrame) requestAnimationFrame(restore); else setTimeout(restore, 0);
  });
}
/* PHASE_HC_NAV_ME_2026-08-24 — the nav's signed-in state.
 *
 * Owner: when the reader is signed in, hide the call-to-action and show their
 * name instead. A signed-out visitor sees exactly what shipped, so the CTA is
 * still the server-rendered default and this only ever takes it away.
 *
 * The nav lives OUTSIDE the router's swapped region, so the chip survives a
 * soft-navigation and this must not build a second one — hence the flag on the
 * header. One fetch per page load, and a signed-out answer leaves the DOM
 * untouched. */
function hcInitNavMe(){
  var hdr = qs('#hc-hdr');
  if (!hdr || hdr.dataset.meBound === '1') return;
  hdr.dataset.meBound = '1';
  var ctas = qsa('.hc-nav-cta', hdr);
  if (!ctas.length) return;                          // nothing to replace
  /* Built server-side like the widget's identity refresh, so it carries the
     persisted query (workspace key, locale) that HELP_BASE alone would drop. */
  var meUrl = <?= json_encode(hc_u('_hcajax=1&_me=1'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  fetch(meUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(me){
      if (!me || !me.signed_in) return;
      var name = String(me.name || '').trim();
      if (name === '') return;                       // a nameless chip helps nobody
      /* Initials: first letter of the first two words, which is what the avatar
         in the owner's reference shows. Built with textContent throughout — a
         display name is user data and never markup. */
      var parts = name.split(/\s+/).filter(Boolean).slice(0, 2);
      var initials = parts.map(function(p){ return p.charAt(0).toUpperCase(); }).join('');
      var url = String(me.url || '');
      var chip = document.createElement(url ? 'a' : 'span');
      chip.className = 'hc-nav-me';
      if (url) chip.href = url;
      var av = document.createElement('span');
      av.className = 'hc-nav-me-av';
      av.setAttribute('aria-hidden', 'true');
      av.textContent = initials;
      var nm = document.createElement('span');
      nm.className = 'hc-nav-me-name';
      nm.textContent = name;
      chip.appendChild(av); chip.appendChild(nm);
      /* The chip takes the CTA's place in the row rather than being appended to
         the end of the bar, so the nav's order is unchanged. */
      ctas[0].parentNode.insertBefore(chip, ctas[0]);
      hdr.classList.add('hc-nav-signedin');          // CSS hides the CTAs
    })
    .catch(function(){ /* signed out, offline, or blocked: the CTA simply stays */ });
}

/* PHASE10H_2026-08-11 — the category CAROUSEL. Owner: "pagination radio animated
 * pagination... as you click see more it swipes to the next keeping the same
 * numbers set." Every category is already in the DOM as a real link (the
 * hide-point mechanism), so this only decides which page of them is visible:
 * page size = the section's own limit, dots are radio-style tabs, See more
 * advances and wraps. Re-bootable from hcInitView like hcInitCatsMore, and
 * idempotent via the same dataset flag. With JS off the control is a plain
 * link to the browse page. */
function hcInitCatsPaginate(){
  var more = qs('.hc-cats-more[data-hc-paginate]');
  if (!more || more.dataset.pgBound === '1') return;
  more.dataset.pgBound = '1';
  /* PHASE_HC_CATS_PAGINATE_2026-08-14 — the SECTION, not the first grid in it.
     A category section can carry up to three presentations (tiles, then a ribbon, say),
     so the tiles a reader sees are spread across several blocks. Binding to the first
     grid found three items, decided that was the whole set, and bailed — which is why
     the control did nothing on a split section. The shell holds them all, and the
     paginated class is a DESCENDANT selector, so putting it there covers every block. */
  var shell = (more.closest && more.closest('.hc-home-cats-shell, .hc-subcats')) || null;
  var grid  = shell || qs('.hc-home-cats') || qs('.hc-dir');
  var dots  = qs('.hc-cats-dots');
  if (!grid || !dots) return;                       // fall through to the href
  var items = [].slice.call(grid.querySelectorAll('.hc-home-cat, .hc-dir-card'));
  /* The hide-point rules are `.hc-home-cats:not(.hc-cats-expanded) .hc-home-cat-more` and
     the same shape for `.hc-dir` — they test the GRID itself, not an ancestor. So the
     expand class goes on every grid in the section while the paginated class goes on the
     shell, whose rule is a descendant selector. Putting both on the shell left the extras
     hidden by their own rule and every page past the first came up empty. */
  var grids = [].slice.call(grid.querySelectorAll('.hc-home-cats, .hc-dir'));
  if (!grids.length && grid.matches && grid.matches('.hc-home-cats, .hc-dir')) grids = [grid];
  var hidden = items.filter(function(i){ return /-more(\s|$)/.test(i.className); }).length;
  var size = items.length - hidden;
  if (size < 1 || items.length <= size) return;      // one page: leave the link alone
  var pages = Math.ceil(items.length / size);
  /* PHASE_HC_CATS_PAGINATE_2026-08-14 — the grid is NOT paginated until the control is
     clicked. Owner: *"if show all not clicked it renders as today; on click it reveals
     pagination. click it shows the next list of the set numbers."* It used to switch into
     paged mode on load, so the dots were there before anyone asked for them. */
  dots.hidden = true;
  var started = false;
  var page = 0;
  var blocks = [].slice.call(grid.querySelectorAll('.hc-cats-block'));
  var wrap = (more.closest && more.closest('.hc-cats-more-wrap')) || dots.parentElement;
  /* PHASE_HC_CATS_RAILFIT_2026-08-24 — cap the rail to the room it actually has.
     The widget's page column sizes itself to its widest child, so a rail of
     nineteen dots made the panel 498px wide inside a 358px frame and the header
     visibly spilled sideways. The cap has to be MEASURED: a percentage resolves
     against that same over-wide column and reads as no cap at all, and a fixed
     number is wrong the moment the label is translated. Viewport, less the insets
     the row already sits inside, less the chip and the gap beside it. Under the
     cap the rail still holds every dot at full size — it scrolls sideways. */
  function fitRail(){
    /* COLLAPSE BEFORE MEASURING. Releasing the cap first (max-width:none) lets the
       rail inflate the very column we are about to measure: the row gets wider,
       the inset reads smaller, so the next cap comes out LARGER — a feedback loop
       that gave the rail its full width back after a few pages and put the widget
       back into horizontal scroll. At zero it cannot influence anything, so what
       we measure is the room the rest of the row actually leaves. */
    dots.style.maxWidth = '0px';
    void dots.offsetWidth;                           // settle the collapsed layout
    var vw    = document.documentElement.clientWidth;
    var inset = wrap.getBoundingClientRect().left - document.documentElement.getBoundingClientRect().left;
    var gap   = parseFloat(getComputedStyle(wrap).columnGap) || 0;
    var room  = Math.floor(vw - (inset * 2) - more.getBoundingClientRect().width - gap);
    /* Owner's ceiling: the rail never takes more than 80% of the surface it is on
       — in a 470px widget that is 376px, comfortably short of the edge — and it
       still gives way to the measured room whenever that is tighter. Two limits,
       the smaller one wins, so a bad measurement can only ever be overruled DOWN. */
    room = Math.max(60, Math.min(room, Math.floor(vw * 0.8)));
    dots.style.maxWidth = room + 'px';
    return room;
  }
  /* Belt and braces: whatever the arithmetic said, if the row still reaches past
     the surface, drop dots until it does not. Layout is the authority here, not
     the estimate — this is what makes "never wider than the widget" a fact rather
     than a calculation that has to be right. */
  function trimToFit(){
    var guard = 40;
    while (guard-- > 0){
      var vw   = document.documentElement.clientWidth;
      var over = document.documentElement.scrollWidth > vw ||
                 Math.round(wrap.getBoundingClientRect().right) > vw;
      if (!over) break;
      var shown = [].filter.call(dots.children, function(d){ return !d.hidden; });
      if (shown.length <= 3) break;
      var last = shown[shown.length - 1], first = shown[0];
      (last.getAttribute('aria-selected') === 'true' ? first : last).hidden = true;
    }
  }
  /* How many dots that room actually holds. Not a fixed number: on the page they
     all fit and all are shown, and the widget keeps as many as its width allows
     rather than a hard seven. Measured from the dots themselves, so restyling
     them cannot make this arithmetic wrong — the selected one is a wider pill,
     which is the extra term. */
  function dotsThatFit(room){
    var kids = dots.children;
    if (!kids.length) return 0;
    var gap = parseFloat(getComputedStyle(dots).columnGap) || 9;
    var normal = 0, active = 0;
    for (var i = 0; i < kids.length; i++){
      var wdt = kids[i].getBoundingClientRect().width;
      if (!wdt) continue;
      if (kids[i].getAttribute('aria-selected') === 'true') active = wdt; else normal = wdt;
    }
    if (!normal) normal = 9;
    if (!active) active = normal;
    return Math.max(3, Math.floor((room + gap - (active - normal)) / (normal + gap)));
  }
  /* PHASE_HC_CATS_STAYPUT_2026-08-24 — the reader stays where they are.
     The section's top never moves when the cards inside it change, so mid-page
     this is already a no-op; it earns its keep only when the document ends up
     shorter than the current scroll and the browser clamps the position. The
     correction must be instant — the page sets scroll-behavior:smooth, which
     would otherwise turn it into a visible glide. Nothing here reserves height:
     an earlier attempt pinned the section to its tallest page and that dead
     space was worse than the jump it prevented. */
  function keepPlace(fn){
    /* THE CONTROL IS THE FIXED POINT, not the scroll offset. Measuring scrollY
       said this was already still — and it was, scrollY never moved — but the
       cards above the control change height from page to page, so the button
       itself slid up to 222px out from under the cursor. That is what reads as
       the page scrolling. Pin the button: whatever it moved, move the scroll the
       same way, in the SAME frame.

       `behavior:'instant'`, never 'auto'. 'auto' does not mean instant — it means
       "use the CSS scroll-behavior", and this page sets `scroll-behavior:smooth`,
       so the correction ANIMATED. A silent correction became a visible glide, and
       in the widget, where a page swap can move the control 460px, that glide is
       precisely what reads as "the widget scrolls when you click See more".

       At the very bottom of the document there may be no room left to scroll
       into, which no compensation can solve; everywhere else this holds exactly. */
    var y0 = window.scrollY || document.documentElement.scrollTop || 0;
    var t0 = more.getBoundingClientRect().top;
    fn();
    var drift = Math.round(more.getBoundingClientRect().top - t0);
    if (drift) window.scrollTo({ top: y0 + drift, left: 0, behavior: 'instant' });
  }
  function start(){
    if (started) return;
    started = true;
    keepPlace(function(){
      grids.forEach(function(g){ g.classList.add('hc-cats-expanded'); });
      grid.classList.add('hc-cats-paginated');
      dots.hidden = false;
      paint();
    });
  }
  function paint(){
    items.forEach(function(el, i){
      el.classList.toggle('hc-pg-on', i >= page * size && i < (page + 1) * size);
    });
    /* PHASE_HC_CATS_EMPTYBLOCK_2026-08-24 — a presentation with nothing on this
       page takes no room. A section can lead with tiles and continue in cards;
       from page two on the tiles block holds nothing, yet the gap the next block
       carries is authored inline and was still being paid, so the section opened
       with dead space that grew with every presentation above it. The first block
       that actually has cards is the lead: nothing precedes it, so it carries no
       gap. A spent block BETWEEN two full ones keeps its separation. */
    var lead = true;
    blocks.forEach(function(b){
      var live = !!b.querySelector('.hc-pg-on');
      b.classList.toggle('hc-pg-empty', !live);
      b.classList.toggle('hc-pg-lead', live && lead);
      if (live) lead = false;
    });
    /* PHASE_HC_CATS_DOTWIN_2026-08-24 — show as many dots as the room HOLDS.
       On the page they all fit, so the whole rail is there and the reader can
       judge the size of the set. A 470px widget panel cannot hold nineteen
       beside the label, and forcing it is what scrolled the widget sideways —
       so it keeps the number that fits, the window sliding to keep the current
       page near its middle. Nothing is dropped that there was room for. */
    var kids = [].slice.call(dots.children);
    var win  = dotsThatFit(fitRail());
    var lo = 0, hi = kids.length;
    if (kids.length > win){
      lo = Math.max(0, Math.min(page - (win >> 1), kids.length - win));
      hi = lo + win;
    }
    kids.forEach(function(d, i){
      d.hidden = i < lo || i >= hi;
      d.setAttribute('aria-selected', i === page ? 'true' : 'false');
    });
    trimToFit();
    /* PHASE_HC_CATS_DOTRAIL_2026-08-24 — the rail follows the current page.
       Every dot stays in the rail at full size: the length of it is how a reader
       judges how much there is, and hiding all but a handful reads as a much
       smaller set than it is. When the rail is wider than the room it has, it
       scrolls sideways rather than folding onto a second line, and the active dot
       is kept centred in that strip. Scrolling the STRIP by its own scrollLeft —
       never scrollIntoView, which would take the whole page with it. */
    var active = kids[page];
    if (active && dots.scrollWidth > dots.clientWidth + 1){
      var ar = active.getBoundingClientRect(), dr = dots.getBoundingClientRect();
      dots.scrollLeft += (ar.left - dr.left) - (dr.width - ar.width) / 2;
    }
  }
  function goTo(n, animate){
    var next = ((n % pages) + pages) % pages;
    if (next === page) return;
    var reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (!animate || reduced){ keepPlace(function(){ page = next; paint(); }); return; }
    grid.classList.add('hc-pg-out');
    setTimeout(function(){
      keepPlace(function(){ page = next; paint(); });
      grid.classList.remove('hc-pg-out');
      grid.classList.add('hc-pg-in');
      void grid.offsetWidth;                         // commit the +18px start frame
      grid.classList.remove('hc-pg-in');
    }, 175);
  }
  for (var i = 0; i < pages; i++){
    var d = document.createElement('button');
    d.type = 'button'; d.className = 'hc-cats-dot'; d.setAttribute('role', 'tab');
    d.setAttribute('aria-label', (dots.getAttribute('data-page-label') || 'Page {n}').replace('{n}', String(i + 1)));
    (function(n){ d.addEventListener('click', function(){ goTo(n, true); }); })(i);
    dots.appendChild(d);
  }
  fitRail();                                         // cap it before it is ever shown
  var fitT;
  window.addEventListener('resize', function(){
    if (!started) return;
    clearTimeout(fitT);
    fitT = setTimeout(paint, 120);                   // paint() re-measures the rail
  });
  more.addEventListener('click', function(e){
    e.preventDefault(); e.stopPropagation();
    /* the first click REVEALS the pagination and moves to the next set; after that it
       simply advances */
    if (!started) { start(); goTo(1, true); return; }
    goTo(page + 1, true);
  });
}
function hcSetLoading(on){ if(IS_FULL_EMBED || !HC_PAGE_LOADER) return; if(routeLoader){ routeLoader.classList.toggle('show', !!on); routeLoader.setAttribute('aria-hidden', on ? 'false' : 'true'); } }
function hcToast(msg){ if(!toast) return; toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function(){ toast.classList.remove('show'); }, 3200); }
if (!IS_EMBED) {
  window.addEventListener('scroll', function(){
    var st = window.scrollY || window.pageYOffset;
    var dh = document.documentElement.scrollHeight - window.innerHeight;
    if (prog && dh > 0) prog.style.width = Math.min(100,(st/dh)*100)+'%';
    if (topBtn) topBtn.classList.toggle('show', dh > 0 && st >= (dh - 80));
  },{passive:true});
  if (topBtn) topBtn.addEventListener('click', function(){
    window.scrollTo({top:0,behavior:'smooth'});
  });
}

/* TOC builder */
function hcInitToc(ctx){
  var body    = qs('#hc-article-body', ctx);
  var toc     = qs('#hc-toc-list', ctx);
  var tocCard = qs('#hc-toc-card', ctx);
  if (!body || !toc) return;
  var heads = qsa('h2,h3', body);
  toc.innerHTML = '';
  if (!heads.length){ if(tocCard) tocCard.style.display='none'; return; }
  if(tocCard) tocCard.style.display='';
  heads.forEach(function(h,i){
    var id = 'hc-h-'+i;
    h.id = id;
    var li = document.createElement('li');
    var a  = document.createElement('a');
    a.href = '#'+id;
    a.textContent = h.textContent;
    a.className = h.tagName==='H3'?'h3':'';
    li.appendChild(a); toc.appendChild(li);
  });
  if ('IntersectionObserver' in window) {
    var obs = new IntersectionObserver(function(entries){
      entries.forEach(function(e){
        var lnk = toc.querySelector('a[href="#'+e.target.id+'"]');
        if(lnk) lnk.classList.toggle('hc-act',e.isIntersecting);
      });
    },{rootMargin:'-72px 0px -60% 0px'});
    heads.forEach(function(h){ obs.observe(h); });
  }
}

/* Feedback */
/* PHASE6_2026-08-07 — ARTICLE DISCUSSIONS, the client.
 *
 * Everything goes through the same-origin adapter on this page, never to the OpsIQ
 * host, so a reverse-proxied help centre works identically to a direct one. The
 * CSRF token is handed back by the thread read and only exists for a signed-in
 * reader, so a signed-out visitor holds nothing worth stealing.
 *
 * The whole block is defensive: if any of this throws, the article is unaffected —
 * a discussion failing to load must never take down the page it hangs off. */
window.hcInitDiscussion=function(){
  var root = document.getElementById('hc-disc');
  if (!root) return;
  if (root.dataset.discussionBound === '1') return;
  root.dataset.discussionBound = '1';

  var artId = parseInt(root.getAttribute('data-article') || '0', 10);
  if (!(artId > 0)) return;

  var elBody  = document.getElementById('hc-disc-body');
  var elFoot  = document.getElementById('hc-disc-foot');
  var elCount = document.getElementById('hc-disc-count');
  var token   = '';

  var T = <?= json_encode([
      'empty'    => $__t('comments_empty', 'No comments yet. Be the first to add one.'),
      'loadFailed' => $__t('comments_load_failed', 'The discussion could not load.'),
      'retry'    => $__t('comments_retry', 'Try again'),
      'placeholder' => $__t('comments_placeholder', 'Add to the discussion'),
      'send'     => $__t('comments_send', 'Post comment'),
      'sending'  => $__t('comments_sending', 'Posting…'),
      'pending'  => $__t('comments_pending', 'Awaiting review'),
      'held'     => $__t('comments_held', 'Thanks. Your comment is waiting for review.'),
      'failed'   => $__t('comments_failed', 'That did not send. Try again.'),
      'one'      => $__t('comments_one', '1 comment'),
      'many'     => $__t('comments_many', '{n} comments'),
      'reply'    => $__t('comments_reply', 'Reply'),
      'edit'     => $__t('comments_edit', 'Edit'),
      'remove'   => $__t('comments_remove', 'Delete'),
      'report'   => $__t('comments_report', 'Report'),
      'save'     => $__t('comments_save', 'Save'),
      'cancel'   => $__t('comments_cancel', 'Cancel'),
      'edited'   => $__t('comments_edited', 'edited'),
      /* TWO-CLICK ARM, never a native confirm(): the first click turns the button
       * into its own confirmation and the second one acts. */
      'confirmRemove' => $__t('comments_confirm_remove', 'Delete for good?'),
      'confirmReport' => $__t('comments_confirm_report', 'Report to the team?'),
      'reported' => $__t('comments_reported', 'Thanks, the team will take a look.'),
      'replyPh'  => $__t('comments_reply_ph', 'Write a reply'),
      'watch'    => $__t('comments_watch', 'Email me about new comments'),
      'watching' => $__t('comments_watching', 'You will get an email about new comments'),
      'justNow'  => $__t('comments_just_now', 'just now'),
      'mAgo'     => $__t('comments_m_ago', '{n}m ago'),
      'hAgo'     => $__t('comments_h_ago', '{n}h ago'),
      'dAgo'     => $__t('comments_d_ago', '{n}d ago'),
  ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var signedIn = false;
  var loadSeq = 0;
  var loadInFlight = false;
  var retryTimer = 0;
  var identityRetryTimer = 0;
  var identityRetryCount = 0;

  function esc(s){ var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }

  /* A raw "2026-08-07 16:54:26" tells a reader nothing they wanted to know. What
   * matters in a conversation is how long ago, until it stops mattering — then the
   * date is the useful thing again. */
  function whenText(iso){
    if (!iso) return '';
    var t = Date.parse(String(iso).replace(' ', 'T') + 'Z');
    if (isNaN(t)) t = Date.parse(iso);
    if (isNaN(t)) return '';
    var mins = Math.floor((Date.now() - t) / 60000);
    if (mins < 1)     return T.justNow;
    if (mins < 60)    return T.mAgo.replace('{n}', mins);
    if (mins < 1440)  return T.hAgo.replace('{n}', Math.floor(mins / 60));
    if (mins < 10080) return T.dAgo.replace('{n}', Math.floor(mins / 1440));
    try { return new Date(t).toLocaleDateString(undefined, { year:'numeric', month:'short', day:'numeric' }); }
    catch(e){ return ''; }
  }

  /* Deterministic hue from the name: the same person is the same colour on every
   * visit, on every device, without storing a thing. All five variants use it, so a
   * thread is scannable by colour before you read a single word. */
  function hue(name){
    /* djb2, accumulated in a 32-bit int and reduced ONCE. Taking the modulo inside
     * the loop keeps the running value tiny and correlated, which is why "Amara" and
     * "Tunde" first came out as two near-identical greens. Spread by the golden
     * angle so neighbouring hashes land far apart on the wheel. */
    var h = 5381, str = String(name || '');
    for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
    return Math.abs(h * 137.508) % 360;
  }

  function url(){
    return HELP_BASE + (HELP_BASE.indexOf('?') === -1 ? '?' : '&')
         + '_hcajax=1&_discussion=1'
         + '&_dc=' + Date.now()
         + (SITE_KEY ? '&site_key=' + encodeURIComponent(SITE_KEY) : '');
  }

  function call(payload, timeoutMs){
    payload.article_id = artId;
    if (token) payload.csrf_token = token;
    var controller = typeof AbortController === 'function' ? new AbortController() : null;
    var timer = setTimeout(function(){ if (controller) controller.abort(); }, timeoutMs || 12000);
    return fetch(url(), {
      method: 'POST',
      credentials: 'same-origin',
      cache: 'no-store',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      signal: controller ? controller.signal : undefined
    }).then(function(r){
      if (!r.ok) throw new Error('Discussion request failed (' + r.status + ')');
      return r.json();
    }).finally(function(){ clearTimeout(timer); });
  }

  /* One renderer for all three variants — the difference is entirely CSS, so a
   * reply is the same markup at any depth and threading costs no extra branch. */
  /* PERMISSIONS ARE THE SERVER'S ANSWER, NOT THE CLIENT'S GUESS. The engine already
   * computes can_edit (own comment, inside 24h), can_remove and can_report for this
   * viewer. Recomputing any of that here would be a second, drifting rulebook — and
   * the adapter re-checks every one of them anyway, so a forged button buys nothing. */
  function actionsFor(c){
    var b = [];
    if (signedIn && !c.pending) b.push(btn('reply',  T.reply,  c.id));
    if (c.can_edit)   b.push(btn('edit',   T.edit,   c.id));
    if (c.can_remove) b.push(btn('remove', T.remove, c.id));
    if (c.can_report) b.push(btn('report', T.report, c.id));
    return b.length ? '<div class="hc-disc-acts">' + b.join('') + '</div>' : '';
  }
  function btn(op, label, id){
    return '<button type="button" class="hc-disc-act" data-op="' + op + '" data-id="' + (id|0) + '">'
         + esc(label) + '</button>';
  }

  function renderOne(c){
    var cls = 'hc-disc-item';
    var meta = '<span class="hc-disc-name">' + esc(c.author && c.author.name) + '</span>'
             + '<span class="hc-disc-when">' + esc(whenText(c.created_at)) + '</span>'
             + (c.edited ? '<span class="hc-disc-when">' + esc(T.edited) + '</span>' : '')
             + (c.pending ? '<span class="hc-disc-pending">' + esc(T.pending) + '</span>' : '');
    var kids = (c.replies && c.replies.length)
      ? '<ul class="hc-disc-replies" role="list">' + c.replies.map(renderOne).join('') + '</ul>'
      : '';
    return '<li class="' + cls + '" data-cid="' + (c.id|0) + '">'
         + '<span class="hc-disc-av" aria-hidden="true" style="--h:' + hue(c.author && c.author.name) + '">'
         +   esc(c.author && c.author.initials) + '</span>'
         + '<div class="hc-disc-main">'
         +   '<div class="hc-disc-meta">' + meta + '</div>'
         +   '<p class="hc-disc-text">' + esc(c.body) + '</p>'
         +   actionsFor(c)
         +   '<div class="hc-disc-slot"></div>'
         +   kids
         + '</div></li>';
  }

  function paintCount(n){
    if (!elCount) return;
    if (!n) { elCount.hidden = true; return; }
    elCount.textContent = n === 1 ? T.one : T.many.replace('{n}', String(n));
    elCount.hidden = false;
  }

  function composer(){
    var f = document.createElement('form');
    f.className = 'hc-disc-form';
    f.innerHTML = '<textarea class="hc-disc-input" maxlength="4000" placeholder="' + esc(T.placeholder) + '"></textarea>'
                + '<div class="hc-disc-actions"><button type="submit" class="hc-disc-send">' + esc(T.send) + '</button>'
                + '<p class="hc-disc-note" hidden></p></div>';
    var ta = f.querySelector('textarea'), btn = f.querySelector('button'), note = f.querySelector('.hc-disc-note');
    f.addEventListener('submit', function(e){
      e.preventDefault();
      var body = (ta.value || '').trim();
      if (!body) return;
      btn.disabled = true; btn.textContent = T.sending;
      call({ op: 'create', body: body }).then(function(r){
        btn.disabled = false; btn.textContent = T.send;
        if (r && r.success){
          ta.value = '';
          note.hidden = false;
          note.className = 'hc-disc-note';
          /* A held comment is NOT in the thread yet, so saying "posted" would be a
           * lie the reader discovers by refreshing and finding nothing. */
          note.textContent = (r.comment && r.comment.pending) ? T.held : '';
          if (!note.textContent) note.hidden = true;
          load();
        } else {
          note.hidden = false;
          note.className = 'hc-disc-err';
          note.textContent = (r && r.message) || T.failed;
        }
      }).catch(function(){
        btn.disabled = false; btn.textContent = T.send;
        note.hidden = false; note.className = 'hc-disc-err'; note.textContent = T.failed;
      });
    });
    return f;
  }

  /* ONE delegated listener for every row action. Rows are replaced wholesale on each
   * reload, so per-button listeners would be re-bound on every paint and leak. */
  function slotOf(id){
    var li = elBody.querySelector('[data-cid="' + (id|0) + '"]');
    return li ? li.querySelector('.hc-disc-slot') : null;
  }
  function textOf(id){
    var li = elBody.querySelector('[data-cid="' + (id|0) + '"]');
    return li ? li.querySelector('.hc-disc-text') : null;
  }
  function clearSlots(){
    [].forEach.call(elBody.querySelectorAll('.hc-disc-slot'), function(el){ el.innerHTML = ''; });
  }

  function miniForm(value, placeholder, onSend){
    var f = document.createElement('form');
    f.className = 'hc-disc-form hc-disc-form-inline';
    f.innerHTML = '<textarea class="hc-disc-input" maxlength="4000"></textarea>'
                + '<div class="hc-disc-actions">'
                +   '<button type="submit" class="hc-disc-send"></button>'
                +   '<button type="button" class="hc-disc-act" data-cancel="1"></button>'
                +   '<p class="hc-disc-note" hidden></p>'
                + '</div>';
    var ta = f.querySelector('textarea'), ok = f.querySelector('button[type=submit]'),
        no = f.querySelector('[data-cancel]'), note = f.querySelector('.hc-disc-note');
    ta.placeholder = placeholder; ta.value = value || '';
    ok.textContent = T.save; no.textContent = T.cancel;
    no.addEventListener('click', function(){ clearSlots(); });
    f.addEventListener('submit', function(e){
      e.preventDefault();
      var body = (ta.value || '').trim();
      if (!body) return;
      ok.disabled = true; ok.textContent = T.sending;
      onSend(body, function(err){
        ok.disabled = false; ok.textContent = T.save;
        if (err){ note.hidden = false; note.className = 'hc-disc-err'; note.textContent = err; }
      });
    });
    setTimeout(function(){ ta.focus(); }, 30);
    return f;
  }

  /* A destructive action arms itself on the first click and fires on the second.
   * No native confirm() anywhere in this product. */
  function armed(b, label, run){
    if (b.getAttribute('data-armed') === '1'){ run(); return; }
    var was = b.textContent;
    b.setAttribute('data-armed', '1');
    b.classList.add('is-armed');
    b.textContent = label;
    var reset = function(){
      b.removeAttribute('data-armed'); b.classList.remove('is-armed'); b.textContent = was;
    };
    b._disarm = setTimeout(reset, 4000);
  }

  elBody.addEventListener('click', function(e){
    var retry = e.target.closest && e.target.closest('[data-disc-retry]');
    if (retry){
      clearTimeout(retryTimer);
      load(0, true);
      return;
    }
    var b = e.target.closest && e.target.closest('.hc-disc-act[data-op]');
    if (!b) return;
    var id = parseInt(b.getAttribute('data-id') || '0', 10);
    var op = b.getAttribute('data-op');

    if (op === 'reply'){
      clearSlots();
      var slot = slotOf(id); if (!slot) return;
      slot.appendChild(miniForm('', T.replyPh, function(body, done){
        call({ op: 'create', body: body, parent_id: id }).then(function(r){
          if (r && r.success){ clearSlots(); load(); } else { done((r && r.message) || T.failed); }
        }).catch(function(){ done(T.failed); });
      }));
      return;
    }
    if (op === 'edit'){
      clearSlots();
      var slot2 = slotOf(id), cur = textOf(id); if (!slot2) return;
      slot2.appendChild(miniForm(cur ? cur.textContent : '', T.placeholder, function(body, done){
        call({ op: 'edit', comment_id: id, body: body }).then(function(r){
          if (r && r.success){ clearSlots(); load(); } else { done((r && r.message) || T.failed); }
        }).catch(function(){ done(T.failed); });
      }));
      return;
    }
    if (op === 'remove'){
      armed(b, T.confirmRemove, function(){
        call({ op: 'remove', comment_id: id }).then(function(){ load(); }).catch(function(){});
      });
      return;
    }
    if (op === 'report'){
      armed(b, T.confirmReport, function(){
        call({ op: 'report', comment_id: id, reason: 'other' }).then(function(){
          b.textContent = T.reported; b.disabled = true;
        }).catch(function(){});
      });
      return;
    }
  });

  function watchToggle(subscribed){
    var w = document.createElement('label');
    w.className = 'hc-disc-watch';
    w.innerHTML = '<input type="checkbox"><span></span>';
    var cb = w.querySelector('input'), tx = w.querySelector('span');
    cb.checked = !!subscribed;
    tx.textContent = subscribed ? T.watching : T.watch;
    cb.addEventListener('change', function(){
      var want = cb.checked;
      cb.disabled = true;
      call({ op: 'subscribe', enabled: want ? 1 : 0 }).then(function(r){
        cb.disabled = false;
        if (!r || r.success === false){ cb.checked = !want; return; }
        tx.textContent = cb.checked ? T.watching : T.watch;
      }).catch(function(){ cb.disabled = false; cb.checked = !want; });
    });
    return w;
  }

  function paintLoadFailure(){
    elBody.innerHTML = '<div class="hc-disc-empty">'
      + '<p>' + esc(T.loadFailed) + '</p>'
      + '<button type="button" class="hc-disc-act" data-disc-retry="1">' + esc(T.retry) + '</button>'
      + '</div>';
    elFoot.hidden = false;
  }

  /* A Help Center on a fresh SSO return can read before the browser commits the
   * shared portal session. Re-read briefly and finitely instead of leaving a
   * signed-in visitor at the guest discussion until a manual refresh. */
  function refreshIdentitySoon(){
    if (signedIn || identityRetryCount >= 6) return;
    clearTimeout(identityRetryTimer);
    identityRetryCount++;
    identityRetryTimer = setTimeout(function(){
      if (!document.hidden) load(0, true);
    }, 350 + identityRetryCount * 250);
  }
  function load(attempt, force){
    attempt = attempt || 0;
    if (loadInFlight && !force) return;
    clearTimeout(retryTimer);
    loadInFlight = true;
    var seq = ++loadSeq;
    call({ op: 'thread' }, 12000).then(function(r){
      if (seq !== loadSeq) return;
      loadInFlight = false;
      if (!r || r.enabled === false){ root.style.display = 'none'; return; }
      token = r.csrf_token || '';
      signedIn = !!r.signed_in;
      if (signedIn) identityRetryCount = 0;
      /* Defence in depth: the service excludes removed rows. Filter recursively as
       * well so an old proxy/cache or a future adapter regression can never revive
       * the public "deleted comment" tombstone. Replies to a removed parent remain
       * readable as top-level comments rather than exposing the missing parent. */
      function publicRows(rows){
        var out = [];
        (rows || []).forEach(function(c){
          if (!c || c.removed || String(c.status || "").toLowerCase() === "removed") {
            (c && c.replies ? publicRows(c.replies) : []).forEach(function(reply){ out.push(reply); });
            return;
          }
          c.replies = publicRows(c.replies || []);
          out.push(c);
        });
        return out;
      }
      var list = publicRows(r.comments || []);
      elBody.innerHTML = list.length
        ? '<ul class="hc-disc-list" role="list">' + list.map(renderOne).join('') + '</ul>'
        : '<p class="hc-disc-empty">' + esc(T.empty) + '</p>';
      paintCount(r.count || 0);

      elFoot.hidden = false;
      var signin = document.getElementById('hc-disc-signin');
      if (r.signed_in){
        if (signin) signin.hidden = true;
        if (!elFoot.querySelector('.hc-disc-form')) elFoot.appendChild(composer());
        /* The watch toggle is rebuilt each load so its checked state always matches
         * what the server just said, rather than whatever the reader last clicked. */
        var oldWatch = elFoot.querySelector('.hc-disc-watch');
        if (oldWatch) oldWatch.remove();
        elFoot.appendChild(watchToggle(r.subscribed));
      } else if (signin){
        signin.hidden = false;
      }
      if (!signedIn) refreshIdentitySoon();
    }).catch(function(){
      if (seq !== loadSeq) return;
      loadInFlight = false;
      if (attempt < 1){
        retryTimer = setTimeout(function(){ load(attempt + 1); }, 700);
        return;
      }
      paintLoadFailure();
    });
  }

  /* Start immediately after the article script. `requestIdleCallback` could be
   * postponed indefinitely on busy pages, which left the shell spinning until a
   * refresh. A bounded request plus one automatic retry keeps the article fast and
   * guarantees that this optional section always reaches a useful state. */
  setTimeout(function(){ load(0); }, 0);
  window.addEventListener('pageshow', function(e){
    if (e.persisted) load(0, true);
  });
  document.addEventListener('visibilitychange', function(){
    if (!document.hidden && elBody.querySelector('.hc-disc-loading,[data-disc-retry]')) load(0, true);
  });
};

window.hcFeedback = function(articleId, helpful){
  var wrap = qs('#hc-feedback-'+articleId);
  if(!wrap) return;
  wrap.style.transition = 'opacity .2s';
  wrap.style.opacity = '0';
  setTimeout(function(){
    wrap.style.opacity = '1';
    /* PHASE_HC_FEEDBACK_I18N_2026-08-15 — the confirmation after a vote. Was raw
       English built in JS (so English in all 40 languages), and its support link had
       an empty mail scheme with no address behind it, giving a visitor who clicked
       "Not really" a blank compose window. Uses the contact URL the rest of the page
       already has; with none set the sentence carries no link at all rather than a
       dead one. Kept short: this comment ships to every visitor. */
    var FB_CONTACT = <?= json_encode((string)$txtContactUrl, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    var FB_SORRY   = <?= json_encode((string)$__t('feedback_sorry', 'Sorry to hear that.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    var FB_THANKS  = <?= json_encode((string)$__t('feedback_thanks', 'Thanks for the feedback!'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    var FB_CONTACT_TXT = <?= json_encode((string)$__t('feedback_contact', 'Contact support if you need more help.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    var FB_CONTACT_LINK = <?= json_encode((string)$__t('feedback_contact_link', 'Contact support'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
    var esc = function(t){ var d=document.createElement('div'); d.textContent=t; return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); };
    var sorry = '<p class="hc-feedback-done soft">' + esc(FB_SORRY) + ' '
      + (FB_CONTACT
          ? '<a href="' + esc(FB_CONTACT) + '" style="color:var(--brand-ink,var(--brand));font-weight:600">'
            + esc(FB_CONTACT_LINK) + '</a>'
          : esc(FB_CONTACT_TXT))
      + '</p>';
    wrap.innerHTML = helpful
      ? '<p class="hc-feedback-done ok">'
        +'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
        +' ' + esc(FB_THANKS) + '</p>'
      : sorry;
  }, 200);
  /* PHASE_HELP_PROXY — record the vote SAME-ORIGIN through this page (proxy-safe)
   * rather than cross-origin to the beacon (which fails CORS on a proxied domain). */
  var fbUrl = HELP_BASE + (HELP_BASE.indexOf('?')===-1?'?':'&') + '_hcajax=1&_feedback=1'
            + (SITE_KEY?'&site_key='+encodeURIComponent(SITE_KEY):'');
  fetch(fbUrl,{
    method:'POST',credentials:'same-origin',
    headers:{'Content-Type':'application/json'},
    body:JSON.stringify({article_id:articleId,helpful:helpful,vid:(window.OPSIQ_VID||''),site_key:SITE_KEY})
  }).catch(function(){});
};

/* Autocomplete */
function buildSuggestUrl(term){
  var url = HELP_BASE+'?_hcajax=1&_suggest=1&q='+encodeURIComponent(term);
  if(SITE_KEY) url += '&site_key='+encodeURIComponent(SITE_KEY);
  return url;
}

function renderSuggest(listEl, results, navigateFn){
  listEl.innerHTML = '';
  if(!results||!results.length){ listEl.hidden=true; return; }
  results.forEach(function(r){
    var div = document.createElement('div');
    div.className = 'hc-suggest-item';
    div.setAttribute('role','option');
    div.setAttribute('data-slug', r.slug||'');
    div.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
      +'<span class="hc-suggest-title">'+escHtml(r.title||'')+'</span>';
    /* SC 2.5.2 Pointer Cancellation — navigation used to fire on `mousedown`,
       i.e. on the DOWN-event. Pressing the wrong suggestion was irreversible:
       there was no way to slide off the item and release somewhere harmless to
       abort, which is precisely the up-event behaviour the criterion requires.
       `mousedown` still preventDefaults, because that is what stops the input
       losing focus and closing the list out from under the click; the
       navigation itself has moved to `click`, which fires on release over the
       item and not at all if the pointer left it. */
    div.addEventListener('mousedown',function(e){ e.preventDefault(); });
    div.addEventListener('click',function(e){
      e.preventDefault();
      navigateFn(r.slug);
    });
    listEl.appendChild(div);
  });
  listEl.hidden = false;
  if(listEl.classList.contains('psearch-res')) listEl.classList.add('on');
}

function showErr(errEl){
  if(!errEl) return;
  errEl.textContent = <?= json_encode((string)$__t('search_empty', 'Type a question or keyword first.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  errEl.hidden = false;
  requestAnimationFrame(function(){ errEl.classList.add('show'); });
  clearTimeout(errEl._t);
  errEl._t = setTimeout(function(){
    errEl.classList.remove('show');
    setTimeout(function(){ errEl.hidden = true; }, 180);
  }, 3200);
}

function wireSearch(inputEl, listEl, errEl, formEl){
  if(!inputEl||!listEl) return;
  var timer = null;
  var selIdx = -1;
  var rafPos = null;
  var portalHero = inputEl.closest ? inputEl.closest('#hc-portal-hero') : null;
  var portalAction = portalHero ? qs('.psearch-go', portalHero) : null;

  function submitPortalSearch(){
    var term = inputEl.value.trim();
    if(!term){ showErr(errEl); return; }
    closeList();
    hcNav(HELP_BASE+'?q='+encodeURIComponent(term)+(SITE_KEY?'&site_key='+encodeURIComponent(SITE_KEY):''));
  }

  function bindViewportHandlers(){
    if(listEl._hcVpBound) return;
    listEl._hcVpHandler = schedulePosition;
    window.addEventListener('resize', listEl._hcVpHandler);
    window.addEventListener('scroll', listEl._hcVpHandler, {passive:true});
    document.addEventListener('scroll', listEl._hcVpHandler, true);
    listEl._hcVpBound = true;
  }

  function unbindViewportHandlers(){
    if(!listEl._hcVpBound) return;
    window.removeEventListener('resize', listEl._hcVpHandler);
    window.removeEventListener('scroll', listEl._hcVpHandler);
    document.removeEventListener('scroll', listEl._hcVpHandler, true);
    listEl._hcVpBound = false;
  }

  function ensureSuggestPortal(el){
    qsa('#hc-suggest-hero').forEach(function(node){
      if(node !== el && node.parentNode) node.parentNode.removeChild(node);
    });
    if(el.parentNode !== document.body) document.body.appendChild(el);
    el.classList.add('hc-suggest-portal');
    return el;
  }

  function positionSuggest(){
    if(!listEl || listEl.hidden) return;
    var rect = inputEl.getBoundingClientRect();
    if(!rect || rect.width <= 0){ closeList(); return; }
    var margin = 10;
    var gap = 8;
    var vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
    var vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
    var width = Math.min(Math.max(rect.width, 240), vw - (margin * 2));
    var left = Math.min(Math.max(rect.left, margin), vw - width - margin);
    var below = vh - rect.bottom - gap - margin;
    var above = rect.top - gap - margin;
    var desired = Math.min(360, Math.max(180, Math.floor(vh * 0.46)));
    var useTop = (below < 170 && above > below);
    var maxH = Math.max(120, Math.min(desired, useTop ? above : below));
    var top = useTop ? Math.max(margin, rect.top - gap - maxH) : (rect.bottom + gap);
    listEl.style.left = Math.round(left) + 'px';
    listEl.style.top = Math.round(top) + 'px';
    listEl.style.width = Math.round(width) + 'px';
    listEl.style.maxHeight = Math.round(maxH) + 'px';
  }

  function schedulePosition(){
    if(rafPos){ cancelAnimationFrame(rafPos); rafPos = null; }
    rafPos = requestAnimationFrame(function(){
      rafPos = null;
      positionSuggest();
    });
  }

  listEl = ensureSuggestPortal(listEl);

  function getItems(){ return qsa('.hc-suggest-item', listEl); }

  function closeList(){
    listEl.hidden = true; listEl.classList.remove('on'); listEl.innerHTML = '';
    if(rafPos){ cancelAnimationFrame(rafPos); rafPos = null; }
    unbindViewportHandlers();
    selIdx = -1;
    inputEl.setAttribute('aria-expanded','false');
  }

  function navToSlug(slug){
    closeList();
    hcNav(HELP_BASE+'?article='+encodeURIComponent(slug)+(SITE_KEY?'&site_key='+encodeURIComponent(SITE_KEY):''));
  }

  /* PHASE10K9_2026-08-11 — the search bar's own states. A shell class drives
   * every visual: typing (loading), a result set, or nothing found. Written on
   * the WRAP, not the input, so a variant can dress the whole component. */
  var shellEl = inputEl.closest ? inputEl.closest('.hc-srch-wrap') : null;
  function shell(cls, on){ if (shellEl) shellEl.classList.toggle(cls, !!on); }
  function emptyState(term){
    listEl.innerHTML = '<div class="hc-suggest-empty" role="option" aria-disabled="true">'
      + '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
      + '<span>' + escHtml(HC_SRCH_NO_MATCH.replace('{q}', term)) + '</span></div>';
    listEl.hidden = false;
    bindViewportHandlers(); schedulePosition();
  }

  inputEl.addEventListener('input',function(){
    var term = inputEl.value.trim();
    clearTimeout(timer);
    shell('has-text', term.length > 0);
    if(term.length < 2){ shell('is-loading', false); closeList(); return; }
    shell('is-loading', true);
    timer = setTimeout(function(){
      fetch(buildSuggestUrl(term),{credentials:'same-origin'})
        .then(function(r){ return r.json(); })
        .then(function(d){
          shell('is-loading', false);
          var hits = (d && d.ok && d.results) ? d.results : [];
          if (hits.length) {
            renderSuggest(listEl, hits, navToSlug);
            bindViewportHandlers(); schedulePosition();
          } else if (inputEl.value.trim().length >= 2) {
            /* Nothing found is an ANSWER, not silence: an empty dropdown that
             * simply never opens reads as a broken search. */
            emptyState(inputEl.value.trim());
          }
          inputEl.setAttribute('aria-expanded', hits.length ? 'true' : 'false');
        }).catch(function(){ shell('is-loading', false); });
    }, 250);
  });

  inputEl.addEventListener('keydown',function(e){
    var items = getItems();
    if(e.key==='ArrowDown'){ e.preventDefault(); selIdx=Math.min(selIdx+1,items.length-1); }
    else if(e.key==='ArrowUp'){ e.preventDefault(); selIdx=Math.max(selIdx-1,-1); }
    else if(e.key==='Escape'){ closeList(); inputEl.blur(); return; }
    else if(e.key==='Enter'){
      if(selIdx>=0&&items[selIdx]){
        e.preventDefault();
        var slug = items[selIdx].getAttribute('data-slug');
        if(slug) navToSlug(slug);
        return;
      }
      if(!inputEl.value.trim()){
        e.preventDefault();
        showErr(errEl);
        return;
      }
      if(!formEl && portalHero){
        e.preventDefault();
        submitPortalSearch();
        return;
      }
      closeList();
      return;
    } else { return; }
    items.forEach(function(it,i){ it.classList.toggle('hc-act', i===selIdx); });
  });

  if(portalAction && portalAction.dataset.hcBound!=='1'){
    portalAction.dataset.hcBound='1';
    portalAction.addEventListener('click', submitPortalSearch);
  }

  if(formEl){
    formEl.addEventListener('submit',function(e){
      if(!inputEl.value.trim()){
        e.preventDefault();
        showErr(errEl);
      } else {
        e.preventDefault();
        closeList();
        hcNav(HELP_BASE+'?q='+encodeURIComponent(inputEl.value.trim())+(SITE_KEY?'&site_key='+encodeURIComponent(SITE_KEY):''));
      }
    });
  }

  document.addEventListener('click',function(e){
    if(!listEl.contains(e.target)&&e.target!==inputEl) closeList();
  });

  inputEl.addEventListener('focus', function(){ if(!listEl.hidden){ bindViewportHandlers(); schedulePosition(); } });
}

function hcInitCategoryRail(ctx){
  qsa('.hc-kb-sidebar.is-collapsible', ctx || document).forEach(function(sidebar){
    var nav = qs('.hc-kb-nav', sidebar);
    var btn = qs('[data-kb-toggle]', sidebar);
    if(!nav || !btn || btn.dataset.bound === '1') return;

    function sync(){
      var expanded = btn.getAttribute('aria-expanded') === 'true';
      var total = parseInt(btn.getAttribute('data-total') || '0', 10);
      var moreLabel = btn.getAttribute('data-more-label') || 'Show all categories';
      var lessLabel = btn.getAttribute('data-less-label') || 'Show fewer categories';
      nav.classList.toggle('is-trimmed', !expanded);
      nav.setAttribute('aria-expanded', expanded ? 'true' : 'false');
      btn.textContent = expanded ? lessLabel : (total > 0 ? (moreLabel + ' (' + total + ')') : moreLabel);
    }

    btn.dataset.bound = '1';
    sync();
    btn.addEventListener('click', function(){
      var expanded = btn.getAttribute('aria-expanded') === 'true';
      btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
      sync();
    });
  });
}

function hcInitDrawerRail(ctx){
  qsa(".hc-kb-sidebar.hc-kb-style-drawer", ctx || document).forEach(function(sidebar){
    var head = qs("[data-hc-drawer-toggle]", sidebar);
    var nav = qs(".hc-kb-nav", sidebar);
    if(!head || !nav || head.dataset.drawerBound === "1") return;
    function sync(open){
      sidebar.classList.toggle("is-drawer-open", open);
      head.setAttribute("aria-expanded", open ? "true" : "false");
    }
    head.dataset.drawerBound = "1";
    var drawerMq = window.matchMedia ? window.matchMedia("(max-width:920px)") : null;
    sync(drawerMq ? !drawerMq.matches : true);
    if(drawerMq && drawerMq.addEventListener) drawerMq.addEventListener("change", function(e){ sync(!e.matches); });
    head.addEventListener("click", function(){ sync(!sidebar.classList.contains("is-drawer-open")); });
    head.addEventListener("keydown", function(e){
      if(e.key !== "Enter" && e.key !== " ") return;
      e.preventDefault();
      sync(!sidebar.classList.contains("is-drawer-open"));
    });
  });
}

/* PHASE_HC_WIDGET — the panel's hamburger sheet. This MUST live in hcInitView()'s
 * re-init path, not in a one-shot script tag: the router replaces #hc-page wholesale
 * (page.outerHTML = data.html), so a tag that ran once at load was dead the moment
 * you clicked a category — server markup swapped in, sheet gone, hamburger stuck
 * hidden. That is exactly the "click category and the hamburger is not there" bug.
 * It ADOPTS the existing rail node rather than re-rendering the categories, so the
 * site's sidebar style, tree, "show all" toggle and active state all come along.
 * No-ops on every non-widget page. */
function hcInitWidgetPanel(){
  var page = qs('#hc-page.hc-widget');
  if(!page) return;
  var btn = qs('#hc-w-menu');
  if(!btn) return;
  var rail = qs('#hc-page.hc-widget .hc-page-shell > .hc-kb-sidebar')
          || qs('#hc-page.hc-widget .hc-sidebar .hc-kb-sidebar');
  /* No rail on this view (the home tiles ARE the index) — no hamburger either. */
  if(!rail) return;

  var old = qs('.hc-w-sheet');
  if(old && old.parentNode) old.parentNode.removeChild(old);

  /* Related articles live in the article sidebar, which the panel hides — so in
   * widget mode they were not rendering at all. Move the card to the foot of the
   * article, where a reader actually wants "what next" (leaving the hidden aside
   * un-touched on every other surface). */
  var relList = qs('#hc-page.hc-widget .hc-sidebar .hc-rel-list');
  var artCard = qs('#hc-page.hc-widget .hc-art-card');
  if(relList && artCard){
    var relCard = relList.closest('.hc-side-card');
    if(relCard && artCard.parentNode){
      relCard.classList.add('hc-w-related');
      artCard.parentNode.insertBefore(relCard, artCard.nextSibling);
    }
  }

  var sheet = document.createElement('div');
  sheet.className = 'hc-w-sheet';
  sheet.id = 'hc-w-sheet';
  var scrim = document.createElement('div');
  scrim.className = 'hc-w-sheet-scrim';
  var panel = document.createElement('div');
  panel.className = 'hc-w-sheet-panel';
  panel.setAttribute('role','dialog');
  panel.setAttribute('aria-modal','true');
  var close = document.createElement('button');
  close.type = 'button';
  close.className = 'hc-w-sheet-close';
  close.setAttribute('aria-label', <?= json_encode((string)$__t('close', 'Close'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
  close.innerHTML = '&times;';
  panel.appendChild(close);
  panel.appendChild(rail);
  sheet.appendChild(scrim);
  sheet.appendChild(panel);
  page.appendChild(sheet);

  var open = false, t = null;
  var root = document.documentElement;
  function show(){
    clearTimeout(t);
    sheet.classList.add('is-open');
    /* One scrollbar at a time. The page stops scrolling while the sheet is open,
       so the sheet's rail is the only one on screen instead of sitting alongside
       the page's. The permanently reserved gutter means taking the page's
       scrollbar away shifts nothing sideways. */
    root.classList.add('hc-w-locked');
    /* Two frames: the browser needs one with display:block before the transform
       transition has anything to animate from. */
    requestAnimationFrame(function(){ requestAnimationFrame(function(){ sheet.classList.add('is-in'); }); });
    btn.setAttribute('aria-expanded','true');
    open = true;
  }
  function hide(){
    sheet.classList.remove('is-in');
    root.classList.remove('hc-w-locked');
    btn.setAttribute('aria-expanded','false');
    t = setTimeout(function(){ sheet.classList.remove('is-open'); }, 280);
    open = false;
  }
  /* A soft navigation re-runs this with a fresh #hc-page, so clear any lock the
     previous view left on <html> — that node survives the swap. */
  root.classList.remove('hc-w-locked');
  btn.hidden = false;
  btn.addEventListener('click', function(){ open ? hide() : show(); });
  close.addEventListener('click', hide);
  scrim.addEventListener('click', hide);
  document.addEventListener('keydown', function(e){ if(e.key === 'Escape' && open) hide(); });
}

/* PHASE_HC_MOBILE_WIDGET — the SECOND hamburger: the one in the page's own nav bar
 * that slides the category rail in on a phone. Same job as hcInitWidgetPanel(), and
 * the same re-init contract (the router swaps #hc-page wholesale, so this runs from
 * hcInitView() and must be idempotent).
 *
 * The one thing it does NOT copy from the widget is adopting the rail into a new
 * sheet node. That page is only ever 420px wide, so moving the rail is free; this
 * page is the SAME document a desktop reader sees, and a moved rail would be gone
 * from the grid the moment anyone widened the window. So the rail stays exactly
 * where the server put it and the sheet is pure CSS on a class — nothing to undo. */
function hcInitMobileNav(){
  var page = qs('#hc-page');
  if(!page || page.classList.contains('hc-widget')) return;   /* widget has its own */
  var btn = qs('.hc-w-bar-mobile #hc-w-menu');
  if(!btn) return;
  var rail = qs('#hc-page .hc-page-shell > .hc-kb-sidebar')
          || qs('#hc-page .hc-sidebar .hc-kb-sidebar');
  /* No rail on this view — no hamburger either, exactly like the panel. */
  if(!rail){ btn.hidden = true; return; }

  var root = document.documentElement;
  /* <html> survives the router's swap, so clear a lock the previous view left. */
  root.classList.remove('hc-w-locked');
  page.classList.remove('hc-mnav-open');

  var close = qs('.hc-mnav-close', rail);
  if(!close){
    close = document.createElement('button');
    close.type = 'button';
    close.className = 'hc-mnav-close';
    close.setAttribute('aria-label', <?= json_encode((string)$__t('close', 'Close'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
    close.innerHTML = '&times;';
    rail.insertBefore(close, rail.firstChild);
  }

  /* Where the rail normally lives, so closing puts it back byte-for-byte. Captured
     once per view; a soft navigation re-runs this against fresh server markup. */
  var home = rail.parentNode, after = rail.nextSibling, t = null;
  var drawerHead = qs('[data-hc-drawer-toggle]', rail);

  /* The global phone sheet IS the drawer interaction. A drawer-style rail also
     has its own tablet disclosure, but leaving that disclosure closed after the
     global hamburger opens creates a transparent, empty sheet and forces a
     second click. Keep both state machines aligned while the phone sheet owns
     the rail, including the accessible expanded state. */
  function syncDrawerSheet(open){
    if(!drawerHead || !rail.classList.contains('hc-kb-style-drawer')) return;
    rail.classList.toggle('is-drawer-open', open);
    drawerHead.setAttribute('aria-expanded', open ? 'true' : 'false');
  }

  function isOpen(){ return page.classList.contains('hc-mnav-open'); }
  function show(){
    clearTimeout(t);
    /* Re-parent to #hc-page so position:fixed is viewport-relative — inside the
       page shell an ancestor containing block clipped the sheet's height. */
    if(rail.parentNode !== page) page.appendChild(rail);
    rail.classList.add('hc-mnav-sheet');
    page.classList.add('hc-mnav-open');
    root.classList.add('hc-w-locked');
    syncDrawerSheet(true);
    /* Two frames: the browser needs one with the sheet laid out before the
       transform transition has anything to animate from. */
    requestAnimationFrame(function(){ requestAnimationFrame(function(){
      rail.classList.add('is-in'); page.classList.add('hc-mnav-in');
    }); });
    btn.setAttribute('aria-expanded','true');
  }
  function hide(){
    clearTimeout(t);
    rail.classList.remove('is-in');
    page.classList.remove('hc-mnav-in');
    root.classList.remove('hc-w-locked');
    btn.setAttribute('aria-expanded','false');
    syncDrawerSheet(false);
    t = setTimeout(function(){
      page.classList.remove('hc-mnav-open');
      rail.classList.remove('hc-mnav-sheet');
      /* Put it back exactly where the server rendered it. */
      if(home && rail.parentNode !== home){
        if(after && after.parentNode === home) home.insertBefore(rail, after);
        else home.appendChild(rail);
      }
    }, 300);
  }

  btn.hidden = false;
  btn.setAttribute('aria-expanded','false');
  if(btn.dataset.mnavBound !== '1'){
    btn.dataset.mnavBound = '1';
    btn.addEventListener('click', function(e){ e.preventDefault(); isOpen() ? hide() : show(); });
  }
  if(close.dataset.mnavBound !== '1'){
    close.dataset.mnavBound = '1';
    close.addEventListener('click', hide);
  }
  /* The scrim is #hc-page::after, so its clicks land on #hc-page itself. */
  if(page.dataset.mnavBound !== '1'){
    page.dataset.mnavBound = '1';
    page.addEventListener('click', function(e){
      if(!isOpen()) return;
      if(rail.contains(e.target) || btn.contains(e.target)) return;
      hide();
    });
  }
  /* The global listeners outlive any single view, so they close through whatever
     hide() the CURRENT view installed — that closure owns the rail's home node and
     is the only thing that can put it back in the grid. */
  window.__hcMnavHide = hide;
  if(!window.__hcMnavGlobal){
    window.__hcMnavGlobal = true;
    document.addEventListener('keydown', function(e){
      if(e.key === 'Escape' && qs('#hc-page.hc-mnav-open') && window.__hcMnavHide) window.__hcMnavHide();
    });
    /* Widening past the breakpoint must return the rail to the grid — left as a
       child of #hc-page it would be missing from the desktop layout entirely, and
       a stale scroll lock would leave the page frozen with no sheet to close. */
    if(window.matchMedia){
      var mq = window.matchMedia('(max-width:820px)');
      var onChange = function(){
        if(mq.matches) return;
        if(qs('#hc-page.hc-mnav-open') && window.__hcMnavHide) window.__hcMnavHide();
        document.documentElement.classList.remove('hc-w-locked');
      };
      if(mq.addEventListener) mq.addEventListener('change', onChange);
      else if(mq.addListener) mq.addListener(onChange);
    }
  }
}

/* PHASE_HC_DARK — the nav sun/moon. The <header> lives OUTSIDE #hc-page so it
 * survives the router's swap, which means binding once is enough — but this runs
 * from hcInitView() anyway and must therefore be idempotent, hence the flag.
 * Double-binding would toggle twice per click and look like the button is dead. */
function hcInitThemeToggle(){
  var b = qs('#hc-theme-tog');
  if (!b || b.dataset.bound === '1') return;
  if (!window.OpsIQHelpTheme) return;          /* dark mode off = no API, no button */
  b.dataset.bound = '1';
  b.addEventListener('click', function(){ window.OpsIQHelpTheme.toggle(); });
}

/* PHASE_HC_I18N — the language picker. Same shape as the theme toggle: one public
 * API that the nav select AND any snippet an operator pastes both call, bound from
 * hcInitView() and idempotent (the header survives the router's swap, so a second
 * listener would fire the navigation twice). */
function hcInitLangSwitch(){
  var btn = qs('#hc-lang-btn'), menu = qs('#hc-lang-menu');
  if (!btn || !menu || btn.dataset.bound === '1') return;
  btn.dataset.bound = '1';

  var wrap = qs('#hc-lang');
  /* is-open drives the hover bridge in CSS as well as the state here. */
  function open(){ menu.hidden = false; btn.setAttribute('aria-expanded','true');  if (wrap) wrap.classList.add('is-open'); }
  function close(){ menu.hidden = true;  btn.setAttribute('aria-expanded','false'); if (wrap) wrap.classList.remove('is-open'); }
  btn.addEventListener('click', function(e){
    e.stopPropagation();
    menu.hidden ? open() : close();
  });
  document.addEventListener('click', function(e){
    if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
  });
  document.addEventListener('keydown', function(e){
    if (e.key === 'Escape' && !menu.hidden) { close(); btn.focus(); }
  });
  /* Close when the pointer leaves — but on a GRACE PERIOD, not instantly.
     Closing the moment the cursor leaves punishes a hand that wanders a few pixels
     off the path, and no bridge geometry can cover every diagonal a real hand takes.
     A short delay, cancelled the moment the pointer comes back to the trigger or the
     menu, is what every well-behaved menu does — forgiving of a shaky hand, still
     out of the way when you have genuinely moved on. */
  var closeT = null;
  function cancelClose(){ if (closeT) { clearTimeout(closeT); closeT = null; } }
  function scheduleClose(){
    cancelClose();
    closeT = setTimeout(function(){ if (!menu.hidden) close(); }, 260);
  }
  if (wrap) {
    wrap.addEventListener('mouseleave', function(){
      if (!window.matchMedia || !window.matchMedia('(min-width:821px)').matches) return; // desktop only
      scheduleClose();
    });
    /* Re-entering anywhere in the wrapper (trigger, bridge or menu) calls it off. */
    wrap.addEventListener('mouseenter', cancelClose);
    /* A click anywhere inside must not be undone by a timer already in flight. */
    wrap.addEventListener('click', cancelClose);
  }
  /* The items are real links, so they already work with no JS. This only adds the
     cookie, so the choice survives to the next visit — then lets the link proceed
     normally rather than hijacking the navigation. */
  menu.querySelectorAll('.hc-lang-item').forEach(function(a){
    a.addEventListener('click', function(){
      /* PHASE_HC_I18N_SYNC — mirror the choice into localStorage so the OTHER document
         on this origin (the Help Center page ⇄ the widget panel iframe) hears the
         `storage` event and follows to the same language, exactly like the theme does. */
      try { localStorage.setItem('hc_lang', a.dataset.lang); } catch(e){}
      try { document.cookie = 'hc_lang=' + a.dataset.lang + ';path=/;max-age=31536000;SameSite=Lax'; } catch(e){}
    });
  });
}

/* PHASE_HC_I18N_SYNC — keep those hrefs pointing at the page you are ACTUALLY on.
 * The server renders them correctly, but only for the page it rendered. Categories and
 * articles arrive by AJAX afterwards (see the router's hcInitView call), and the nav sits
 * OUTSIDE the swapped region — so these links kept describing whichever page was loaded
 * first. hcInitLangSwitch cannot fix it either: it binds once and returns early ever
 * after, by design. The result was that reading an article and switching language
 * followed a stale link back to the category you came from, losing the article.
 * The router pushStates the new URL before calling hcInitView, so by the time this runs
 * location is already the page in view — rebuilding from it is always right, however many
 * soft navigations happened since. Rewriting the real href rather than hijacking the
 * click keeps middle-click and open-in-new-tab honest, and leaves the no-JS path alone. */
function hcSyncLangLinks(){
  var items = document.querySelectorAll('.hc-lang-item[data-lang]');
  for (var i = 0; i < items.length; i++) {
    var a = items[i], code = a.getAttribute('data-lang');
    if (!code) continue;
    /* WHICH code rides a clean URL is the server's call, not ours. Read it off the
       original href once — before we start overwriting them — and keep it on the node.
       Staleness never affected the presence of ?lang=, only the page params, so the
       first reading is trustworthy whenever it happens. */
    if (!a.dataset.hcClean) {
      var had = true;
      try { had = new URL(a.href, location.href).searchParams.has('lang'); } catch(e){}
      a.dataset.hcClean = had ? '0' : '1';
    }
    try {
      var u = new URL(window.location.href);
      /* Router junk and the cache-buster never belong in a link — same rule as hc_u_lang. */
      u.searchParams.delete('_hcajax');
      u.searchParams.delete('cb');
      if (a.dataset.hcClean === '1') u.searchParams.delete('lang');
      else                           u.searchParams.set('lang', code);
      a.href = u.toString();
    } catch(e){}
  }
}

/* hcInitView — re-init after every AJAX swap */
/* PHASE10K13_2026-08-12 — THE SIDEBAR COLUMN STICKS AS ONE BLOCK.
 *
 * The owner: "default to stick and not scroll up, then option to allow it to
 * scroll above." The article page already does this — its rail is a single
 * <aside> and one `position:sticky` holds the whole column. The category page
 * cannot: its rail and its modules are SIBLINGS in the page grid (fifty-four
 * rules select the rail as a direct child of the shell, so it cannot be
 * wrapped), and sticky is per element. Giving them all the same `top` would
 * pin them on top of each other.
 *
 * So each module is stuck at the offset where it already sits: the rail's own
 * sticky top, plus the heights and gaps of everything above it in the column.
 * They then hold their exact resting arrangement while the page scrolls, which
 * is what "sticks as one block" means.
 *
 * A module that would not FIT on screen at that offset is left alone — a card
 * stuck below the fold can never be scrolled to, and an unreachable card is
 * worse than one that scrolls. With JavaScript off nothing is stuck and the
 * column simply scrolls, which is how it behaved before this existed.
 */
function hcSideStick(){
  var page = document.getElementById('hc-page');
  if (!page) return;
  var shell = qs('.hc-page-shell', page);
  if (!shell) return;
  var rail = shell.querySelector(':scope > .hc-kb-sidebar');
  var mods = [].slice.call(shell.children).filter(function(e){
    return e !== rail && !e.classList.contains('hc-page-content');
  });
  var clear = function(){
    shell.classList.remove('hc-side-stuck');
    if (rail) rail.style.removeProperty('--hc-side-top');
    mods.forEach(function(m){ m.style.removeProperty('--hc-side-top'); });
  };
  /* The operator can hand the column back to the page, and below the rail
     breakpoint the column is stacked under the content, where sticky is wrong. */
  if (page.classList.contains('hc-side-scroll') || !rail || !mods.length
      || (window.innerWidth || 0) <= 920) { clear(); return; }

  var railTop = parseFloat(getComputedStyle(rail).top);
  if (!isFinite(railTop)) railTop = 92;
  var gap = parseFloat(getComputedStyle(shell).rowGap) || 18;

  /* A column TALLER than the screen cannot be pinned at the top: everything
     past the fold would be permanently unreachable. It is anchored by its
     BOTTOM instead — it rides up with the page until its last card is fully in
     view, then holds. That is the owner's own description: the rail scrolls up
     only once you have reached the bottom of it. */
  var total = rail.offsetHeight;
  mods.forEach(function(m){ total += gap + m.offsetHeight; });
  var top = Math.min(railTop, window.innerHeight - 16 - total);

  rail.style.setProperty('--hc-side-top', top + 'px');
  var offset = top + rail.offsetHeight + gap;
  mods.forEach(function(m){
    m.style.setProperty('--hc-side-top', offset + 'px');
    offset += m.offsetHeight + gap;
  });
  shell.classList.add('hc-side-stuck');
}
if (!window.__hcSideStickBound) {
  window.__hcSideStickBound = 1;
  var __hcSideT = null;
  window.addEventListener('resize', function(){
    if (__hcSideT) clearTimeout(__hcSideT);
    __hcSideT = setTimeout(hcSideStick, 120);
  });
}

/* PHASE10K18_2026-08-12 — THE LARGE BENTO TILE FILLS ITS OWN SPACE.
 *
 * Owner: "do not hardcode: large card = 10 articles or any other fixed value.
 * The large bento card should determine the number dynamically."
 *
 * So nothing here counts rows. It measures: the tile's own content box, minus
 * whatever the heading, description, subcategory chips and the continuation
 * link actually occupy at the current type size and breakpoint, and reveals
 * rows while they still land inside that budget. Change the font, the card
 * size, the density or the window and the answer changes with them, because the
 * answer is only ever "what fitted when I last looked".
 *
 * ONE reveal, ONE read pass, ONE write pass — §32. Rows are unhidden together,
 * every rect is read in a single loop, and only then is anything hidden again,
 * so the browser lays out twice per run rather than once per row.
 */
/* PHASE10K19_2026-08-12 — THE SCROLLING DECK'S BEHAVIOUR.
 *
 * Owner §14: previous/next, play-pause, pagination, touch, drag, trackpad,
 * keyboard, snap, accessible focus — and autoplay that pauses on hover, on
 * focus and on any manual interaction.
 *
 * The deck is still a scroll container, so touch, trackpad, momentum and snap
 * are the platform's and cost nothing (§32). Everything here is the layer above:
 * paging by the width actually on screen, a dot per page, and an autoplay that
 * yields to the visitor the moment they touch it.
 */
function hcDeckInit(){
  var decks = document.querySelectorAll('[data-hc-deck]');
  for (var d = 0; d < decks.length; d++) {
    var deck = decks[d];
    if (deck.dataset.deckBound === '1') { hcDeckSync(deck); continue; }
    deck.dataset.deckBound = '1';

    var track = deck.querySelector('[data-deck-track]');
    if (!track) continue;
    var prev = deck.querySelector('[data-deck-prev]');
    var next = deck.querySelector('[data-deck-next]');
    var play = deck.querySelector('[data-deck-play]');
    var dots = deck.querySelector('[data-deck-dots]');

    var page = function(){ return Math.max(1, track.clientWidth); };
    var go = function(dir){ hcDeckScroll(track, track.scrollLeft + dir * page()); hcDeckHold(deck); };
    if (prev) prev.addEventListener('click', function(){ go(-1); });
    if (next) next.addEventListener('click', function(){ go(1); });

    /* Keyboard: the track is focusable and answers the arrows, which is what
       makes the deck reachable without a pointer. */
    track.addEventListener('keydown', function(e){
      if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
      else if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
      else if (e.key === 'Home') { e.preventDefault(); hcDeckScroll(track, 0); hcDeckHold(deck); }
      else if (e.key === 'End') { e.preventDefault(); hcDeckScroll(track, track.scrollWidth); hcDeckHold(deck); }
    });

    /* Mouse drag. Pointer events cover mouse and pen; touch already scrolls
       natively, so it is deliberately left alone. */
    var down = false, sx = 0, sl = 0, moved = 0;
    track.addEventListener('pointerdown', function(e){
      if (e.pointerType === 'touch') return;
      down = true; moved = 0; sx = e.clientX; sl = track.scrollLeft;
      deck.classList.add('is-dragging'); hcDeckHold(deck);
    });
    track.addEventListener('pointermove', function(e){
      if (!down) return;
      var dx = e.clientX - sx; moved = Math.abs(dx);
      track.scrollLeft = sl - dx;
    });
    var release = function(){
      if (!down) return;
      down = false; deck.classList.remove('is-dragging');
      /* Let snap take the last word once the drag ends. */
      track.scrollBy({left: 0, behavior: hcDeckMotion()});
    };
    track.addEventListener('pointerup', release);
    track.addEventListener('pointercancel', release);
    track.addEventListener('pointerleave', release);
    /* A drag that travelled must not also open the card it finished on. */
    track.addEventListener('click', function(e){
      if (moved > 6) { e.preventDefault(); e.stopPropagation(); moved = 0; }
    }, true);

    track.addEventListener('scroll', function(){
      if (deck.__deckRaf) return;
      deck.__deckRaf = setTimeout(function(){ deck.__deckRaf = null; hcDeckSync(deck); }, 90);
    });

    if (dots) dots.addEventListener('click', function(e){
      var b = e.target.closest ? e.target.closest('.hc-deck-dot') : null;
      if (!b) return;
      hcDeckScroll(track, parseInt(b.dataset.deckTo, 10) || 0);
      hcDeckHold(deck);
    });

    /* AUTOPLAY. Off unless the operator turned it on, and it yields to the
       visitor: hover, focus inside, or any manual move stops it. */
    if (deck.getAttribute('data-deck-autoplay') === '1') {
      deck.__deckPlaying = true;
      var tick = function(){
        if (!deck.__deckPlaying || deck.__deckHeld) return;
        if (document.hidden) return;
        var atEnd = track.scrollLeft + track.clientWidth >= track.scrollWidth - 4;
        hcDeckScroll(track, atEnd ? 0 : track.scrollLeft + page());
      };
      var iv = Math.max(2, parseInt(deck.getAttribute('data-deck-interval'), 10) || 6);
      deck.__deckTimer = setInterval(tick, iv * 1000);
      deck.addEventListener('mouseenter', function(){ deck.__deckHeld = true; });
      deck.addEventListener('mouseleave', function(){ if (!deck.__deckStopped) deck.__deckHeld = false; });
      deck.addEventListener('focusin',  function(){ deck.__deckHeld = true; });
      deck.addEventListener('focusout', function(){ if (!deck.__deckStopped) deck.__deckHeld = false; });
      if (play) play.addEventListener('click', function(){
        deck.__deckStopped = !deck.__deckStopped;
        deck.__deckPlaying = !deck.__deckStopped;
        deck.__deckHeld = deck.__deckStopped;
        deck.classList.toggle('is-stopped', deck.__deckStopped);
        play.setAttribute('aria-pressed', deck.__deckStopped ? 'false' : 'true');
        play.setAttribute('aria-label', play.getAttribute(deck.__deckStopped ? 'data-label-play' : 'data-label-pause') || '');
      });
    }
    hcDeckSync(deck);
  }
}
/* A VISITOR WHO DRIVES IT KEEPS IT. Owner: "you can operate it manually" — and
 * once someone has, a carousel that resumes on its own is fighting them. Any
 * arrow, dot, drag or arrow-key stops autoplay for the session; the play control
 * is how it starts again, which is also why that control has to exist. */
function hcDeckHold(deck){
  deck.__deckHeld = true;
  deck.__deckStopped = true;
  deck.__deckPlaying = false;
  deck.classList.add('is-stopped');
  var play = deck.querySelector('[data-deck-play]');
  if (play) {
    play.setAttribute('aria-pressed', 'false');
    play.setAttribute('aria-label', play.getAttribute('data-label-play') || '');
  }
}
/* Mandatory snap and PROGRAMMATIC scrolling do not cooperate: with
 * `scroll-snap-type:x mandatory` the container re-snaps to its current target
 * the moment script moves it, so every arrow click landed back where it began —
 * measured, `scrollLeft = 600` resolved to 5 with snap on and to 600 with it
 * off. Snap is what makes the DRAG feel right, so it is suspended for the
 * duration of a scripted move and restored once the container settles. */
function hcDeckScroll(track, left){
  var behavior = hcDeckMotion();
  track.style.scrollSnapType = 'none';
  track.scrollTo({left: Math.max(0, left), behavior: behavior});
  if (track.__deckSnapT) clearTimeout(track.__deckSnapT);
  track.__deckSnapT = setTimeout(function(){ track.style.scrollSnapType = ''; },
                                 behavior === 'smooth' ? 620 : 40);
}
function hcDeckMotion(){
  return (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'auto' : 'smooth';
}
/* Ends, page count and the active dot — recomputed from what is on screen now,
   never from a server-side guess at the viewport. */
function hcDeckSync(deck){
  var track = deck.querySelector('[data-deck-track]');
  if (!track) return;
  var prev = deck.querySelector('[data-deck-prev]');
  var next = deck.querySelector('[data-deck-next]');
  var dots = deck.querySelector('[data-deck-dots]');
  var max  = track.scrollWidth - track.clientWidth;
  if (prev) prev.disabled = track.scrollLeft <= 2;
  if (next) next.disabled = track.scrollLeft >= max - 2;
  if (!dots) return;
  var pages = Math.max(1, Math.ceil(track.scrollWidth / Math.max(1, track.clientWidth)));
  var here  = Math.min(pages - 1, Math.round(track.scrollLeft / Math.max(1, track.clientWidth)));
  if (dots.children.length !== pages) {
    var label = dots.getAttribute('data-page-label') || 'Page {n}';
    var html = '';
    for (var p = 0; p < pages; p++) {
      html += '<button type="button" class="hc-deck-dot" role="tab" data-deck-to="'
            + (p * track.clientWidth) + '" aria-label="' + label.replace('{n}', String(p + 1)) + '"></button>';
    }
    dots.innerHTML = html;
  }
  for (var c = 0; c < dots.children.length; c++) {
    dots.children[c].setAttribute('aria-selected', c === here ? 'true' : 'false');
  }
}
if (!window.__hcDeckBound) {
  window.__hcDeckBound = 1;
  var __hcDeckT = null;
  window.addEventListener('resize', function(){
    if (__hcDeckT) clearTimeout(__hcDeckT);
    __hcDeckT = setTimeout(function(){
      document.querySelectorAll('[data-hc-deck]').forEach(function(d){ hcDeckSync(d); });
    }, 150);
  });
}

function hcBentoFit(){
  var lists = document.querySelectorAll('.hc-dir-v-bento .hc-dir-list[data-hc-bentofit]');
  for (var i = 0; i < lists.length; i++) {
    var list = lists[i];
    var card = list.closest ? list.closest('.hc-dir-card') : null;
    if (!card) continue;
    var base   = parseInt(list.getAttribute('data-hc-bentofit'), 10) || 0;
    var rows   = [].slice.call(list.children);
    var extras = rows.filter(function(r){ return r.classList.contains('hc-bento-extra'); });
    if (!extras.length) continue;
    var foot = card.querySelector('.hc-dir-more');

    /* 1 · WRITE — back to the baseline the operator configured. The slack has to
       be measured with the tile at the height the GRID gives it, not the height
       its own content produces: measuring after revealing is circular, because
       every revealed row makes the card taller and then "fits". */
    extras.forEach(function(r){ r.hidden = true; });

    /* 2 · READ — how much room is left under the configured rows. */
    var listBottom = list.getBoundingClientRect().bottom;
    var floorY = foot
      ? foot.getBoundingClientRect().top
      : card.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(card).paddingBottom) || 0);
    var slack = floorY - listBottom;
    if (slack <= 4) { list.setAttribute('data-hc-bentoshown', String(rows.length - extras.length)); continue; }

    /* 3 · WRITE — reveal them all so every candidate can be measured at once. */
    extras.forEach(function(r){ r.hidden = false; });

    /* 4 · READ — each row's own height. Width is unchanged by revealing, so a
       row's height here is the height it will have when it stays. */
    var heights = extras.map(function(r){ return r.getBoundingClientRect().height; });

    /* 5 · WRITE — keep what the slack pays for, hide the rest. */
    var used = 0, shown = rows.length - extras.length;
    for (var e = 0; e < extras.length; e++) {
      if (used + heights[e] <= slack) { used += heights[e]; shown++; }
      else { extras[e].hidden = true; }
    }
    list.setAttribute('data-hc-bentoshown', String(shown));
    if (base) list.setAttribute('data-hc-bentobase', String(base));
  }
}
if (!window.__hcBentoBound) {
  window.__hcBentoBound = 1;
  var __hcBentoT = null;
  window.addEventListener('resize', function(){
    if (__hcBentoT) clearTimeout(__hcBentoT);
    __hcBentoT = setTimeout(hcBentoFit, 150);
  });
  /* Type metrics change the answer, so re-fit once the real faces are in. */
  if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
    document.fonts.ready.then(function(){ hcBentoFit(); });
  }
}

function hcInitView(){
  hcInitToc();
  hcSideStick();
  hcBentoFit();
  if (typeof window.hcInitDiscussion === 'function') window.hcInitDiscussion();
  hcDeckInit();
  hcInitCategoryRail();
  hcInitDrawerRail();
  hcInitWidgetPanel();
  hcInitMobileNav();
  hcInitThemeToggle();
  hcInitLangSwitch();
  hcSyncLangLinks();   /* after the switch is bound, and after every swap */
  /* PHASE0_2026-08-06 — these two bind DOM inside #hc-page and must re-bind
   * after every router swap. They MUST stay above the hero-input early return
   * below: the home view has a hero, but category/article views may not. */
  hcInitCatsMore();
  hcInitCatsPaginate();
  hcInitQuickTabs();
  hcInitNavMe();

  var portalHero = qs('#hc-portal-hero');
  if(portalHero && portalHero.dataset.actionsBound!=='1'){
    portalHero.dataset.actionsBound='1';
    portalHero.addEventListener('click', function(e){
      var action = e.target.closest ? e.target.closest('[data-nav]') : null;
      if(!action) return;
      var base = portalHero.getAttribute('data-portal-base') || '';
      if(!base) return;
      e.preventDefault();
      var page = action.getAttribute('data-nav') || 'home';
      var sep = base.indexOf('?')===-1 ? '?' : '&';
      location.href = page==='home' ? base : base+sep+'p='+encodeURIComponent(page);
    });
  }

  var heroInput = qs('#hc-hero-input');
  if(!heroInput){
    var staleSuggest = qs('#hc-suggest-hero');
    if(staleSuggest){ staleSuggest.hidden = true; staleSuggest.innerHTML = ''; }
    return;
  }
  var heroWrap = heroInput && heroInput.closest ? heroInput.closest('.hc-srch-wrap') : null;
  var heroSuggest = heroWrap ? qs('#hc-suggest-hero', heroWrap) : qs('#hc-suggest-hero');
  var heroErr = heroWrap ? qs('#hc-hero-search-err', heroWrap) : qs('#hc-hero-search-err');
  var heroForm = heroWrap ? qs('#hc-hero-form', heroWrap) : qs('#hc-hero-form');

  wireSearch(
    heroInput,
    heroSuggest,
    heroErr,
    heroForm
  );
  /* PHASE10K9b — the chrome (shortcut chip, clear button) belongs to the SEARCH
   * BAND only: the band that appears when a page's header is set to "Search bar
   * only". The hero keeps whatever its template draws, and the Portal hero — the
   * Portal's own component — is never touched. */
  if (heroWrap && heroWrap.getAttribute('data-hc-searchband') === '1') {
    hcEnhanceSearch(heroInput, heroForm, heroWrap);
  }
}

/* ── THE SEARCH BAR'S CHROME ──────────────────────────────────────────────────
 * PHASE10K9_2026-08-11 — the owner's brief: the Help Center's OWN search bar
 * becomes the premium component, and Ctrl/⌘-K belongs to IT — not to a second
 * search dropped into the navigation (that one is deleted).
 *
 * The chrome is added HERE rather than in each of the twenty layout variants:
 * every variant renders the same input inside the same shell, so one enhancer
 * dresses them all and a new variant inherits it for free. Both controls are
 * inserted next to the input, in the form's own flex row, so no variant has to
 * make room for an absolutely-positioned overlay.
 */
function hcEnhanceSearch(inputEl, formEl, wrapEl){
  if (!inputEl || !formEl) return;
  if (formEl.getAttribute('data-hc-srch-ready') === '1') return;
  formEl.setAttribute('data-hc-srch-ready', '1');
  if (wrapEl) wrapEl.classList.add('hc-srch-pro');

  /* PHASE10K9f_2026-08-11 — NO CHIP INSIDE THE FIELD. The owner: "remove the
   * Ctrl-K from inside." The shortcut still works — it is bound at the document
   * and focuses this band — but the field stays a field: placeholder, caret and
   * the action, with nothing parked in the middle of it. */
  /* Clear. A real button: it performs an action and must be reachable by tab
   * when there is something to clear. */
  var clear = document.createElement('button');
  clear.type = 'button';
  clear.className = 'hc-srch-clear';
  clear.setAttribute('aria-label', <?= json_encode((string)$__t('clear_search', 'Clear search'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
  clear.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
  clear.addEventListener('click', function(){
    inputEl.value = '';
    if (wrapEl) wrapEl.classList.remove('has-text', 'is-loading');
    inputEl.focus();
    inputEl.dispatchEvent(new Event('input', {bubbles:true}));
  });

  /* THE CHROME IS POSITIONED, NOT INSERTED INTO THE LAYOUT.
   *
   * The twenty templates lay their field out in every way CSS allows — flex
   * rows, grids with fixed tracks, stacked columns. An element inserted into
   * that layout inherits its rules: in the grid templates the chip became a
   * zero-width grid item and its text spilled over the panel's edge. So the
   * chrome lives in the SHELL and is placed against the field's measured box.
   * One geometry, every template, and a new template needs nothing. */
  if (wrapEl) wrapEl.appendChild(clear); else formEl.appendChild(clear);
  wrapEl && wrapEl.classList.add('hc-srch-float');

  var place = function(){
    if (!inputEl.isConnected || !wrapEl) return;
    var wr = wrapEl.getBoundingClientRect(), ir = inputEl.getBoundingClientRect();
    if (!ir.height) return;
    var pad = 12;
    var right = Math.round(wr.right - ir.right + pad);
    var top   = Math.round(ir.top - wr.top + (ir.height - 24) / 2);
    clear.style.right = right + 'px'; clear.style.top = top + 'px';
  };
  /* setTimeout, not rAF: rAF is starved in background and automation tabs, and
   * the first measurement must happen there too. */
  setTimeout(place, 40);
  setTimeout(place, 400);            /* again after webfonts settle the metrics */
  if (!window.__hcSrchPlacers) {
    window.__hcSrchPlacers = [];
    window.addEventListener('resize', function(){
      clearTimeout(window.__hcSrchPlaceT);
      window.__hcSrchPlaceT = setTimeout(function(){
        (window.__hcSrchPlacers || []).forEach(function(fn){ try { fn(); } catch (e) {} });
      }, 120);
    });
  }
  window.__hcSrchPlacers.push(place);

  if (wrapEl && inputEl.value.trim() !== '') wrapEl.classList.add('has-text');

  /* THE CHIP TAKES ITS COLOUR FROM THE FIELD, not from the form around it.
   * Twenty hero templates put this field on white, on glass, on photographs and
   * on near-black; a fixed palette is legible on some of them and invisible on
   * the rest. Reading the input's own resolved colour is the only value that is
   * right on all of them. setTimeout rather than rAF: rAF is starved in
   * background and automation tabs, and this must run there too. */
  setTimeout(function(){
    try {
      var c = getComputedStyle(inputEl).color;
      if (!c) return;
      clear.style.color = c;
      clear.style.background = 'color-mix(in srgb,' + c + ' 12%,transparent)';
    } catch (err) {}
  }, 30);
}

/* Ctrl/⌘-K focuses THIS bar. Bound once at the document, because the Help
 * Center soft-navigates and re-renders the hero on every page swap. */
if (!window.__hcSrchKeyBound) {
  window.__hcSrchKeyBound = 1;
  document.addEventListener('keydown', function(e){
    if (!(e.key === 'k' || e.key === 'K') || !(e.metaKey || e.ctrlKey)) return;
    /* Only when the search band is the page's header. The owner's rule: the
     * shortcut belongs to that option, not to every page that has a field. */
    var band = document.querySelector('[data-hc-searchband="1"]');
    if (!band) return;
    var input = band.querySelector('#hc-hero-input') || document.getElementById('hc-hero-input');
    if (!input) return;
    e.preventDefault();
    var wrap = input.closest ? input.closest('.hc-srch-wrap') : null;
    /* Scroll it into view before focusing: focusing an off-screen field jumps
     * the page in a way that reads as a glitch. */
    try { input.scrollIntoView({block:'center', behavior:'smooth'}); } catch (err) {}
    setTimeout(function(){
      input.focus();
      try { input.select(); } catch (err) {}
      if (wrap) {
        wrap.classList.add('is-called');
        setTimeout(function(){ wrap.classList.remove('is-called'); }, 700);
      }
    }, 60);
  });
  /* Escape gives the page back: clear if there is text, otherwise blur. */
  document.addEventListener('keydown', function(e){
    if (e.key !== 'Escape') return;
    var band = document.querySelector('[data-hc-searchband="1"]');
    var input = band ? band.querySelector('#hc-hero-input') : null;
    if (!input || document.activeElement !== input) return;
    if (input.value !== '') {
      input.value = '';
      input.dispatchEvent(new Event('input', {bubbles:true}));
    } else {
      input.blur();
    }
  });
}

/* In-flight request controller */
var _ctrl = null;

/* AJAX navigation */
function hcNav(url){
  if(_ctrl){ try{ _ctrl.abort(); }catch(e){} }
  var page = qs('#hc-page');
  /* PHASE_HC_WIDGET_LOOK — in the Help look only the BODY waits: dimming #hc-page dimmed the
     hero and the "Loading help center" card flashed over it on every click (the blink). */
  var wlBodyPre = page && page.querySelector('.hc-wl-body');
  if(wlBodyPre){
    wlBodyPre.classList.add('hc-wl-busy');
  } else {
    if(page && !IS_FULL_EMBED && HC_PAGE_LOADER) page.classList.add('hc-loading');
    hcSetLoading(true);
  }

  var ajaxUrl = url + (url.indexOf('?')>=0?'&':'?')+'_hcajax=1';
  _ctrl = typeof AbortController!=='undefined' ? new AbortController() : null;
  var fetchOpts = {credentials:'same-origin',headers:{'Accept':'application/json'}};
  if(_ctrl) fetchOpts.signal = _ctrl.signal;

  fetch(ajaxUrl, fetchOpts)
    .then(function(r){ return r.json(); })
    .then(function(data){
      if(!data.ok) throw new Error('bad');
      hcSetLoading(false);
      /* PHASE_HC_WIDGET_LOOK — the Help look swaps the BODY only: the head and the search
         never re-render, exactly as the storefront panel's never do (owner, 2026-09-14:
         "the storefront hero doesn't change, doesn't reload"). The two things in the head
         that belong to the view, the language links and the search's value, are refreshed
         from the new markup in place. */
      var wlBody = page && page.querySelector('.hc-wl-body');
      if(wlBody){
        var wlTmp = document.createElement('div'); wlTmp.innerHTML = data.html;
        var wlNewPage = wlTmp.querySelector('#hc-page'), wlNewBody = wlTmp.querySelector('.hc-wl-body');
        if(wlNewPage && wlNewBody){
          wlBody.classList.remove('hc-wl-busy');
          wlBody.innerHTML = wlNewBody.innerHTML;
          page.className = wlNewPage.className;
          var wlOldMenu = page.querySelector('.hc-wl-lang-menu'), wlNewMenu = wlTmp.querySelector('.hc-wl-lang-menu');
          if(wlOldMenu && wlNewMenu) wlOldMenu.innerHTML = wlNewMenu.innerHTML;
          var wlOldIn = page.querySelector('#hc-hero-input'), wlNewIn = wlTmp.querySelector('#hc-hero-input');
          /* Opening a result must not empty the box the reader typed it into: the storefront's
             search keeps its text while you read the answer. Only a view that carries a query
             of its own writes the field. */
          if(wlOldIn && wlNewIn && (wlNewIn.value !== '' || wlOldIn.value === '')) wlOldIn.value = wlNewIn.value;
          wlBody.scrollTop = 0;
          if(!IS_FULL_EMBED){
            document.title = data.title || document.title;
            history.pushState({hcUrl:url}, data.title||'', url);
          }
          hcInitView();
          return;
        }
      }
      if(page){
        /* Animate out */
        page.classList.remove('hc-loading');
        page.classList.add('hc-out');
        setTimeout(function(){
          page.outerHTML = data.html;
          var newPage = qs('#hc-page');
          if(newPage){
            newPage.classList.add('hc-out');
            /* Force reflow */
            void newPage.offsetHeight;
            newPage.style.transition = 'opacity .2s var(--ease,ease),transform .2s var(--ease,ease)';
            newPage.classList.remove('hc-out');
          }
          if(!IS_FULL_EMBED){
            document.title = data.title || document.title;
            history.pushState({hcUrl:url}, data.title||'', url);
            window.scrollTo(0,0);
          } else if(page && page.scrollIntoView) {
            page.scrollIntoView({block:'start'});
          }
          hcInitView();
        }, 120);
      }
    })
    .catch(function(err){
      if(err && err.name==='AbortError') return;
      /* A silent fallback hid a real error for a day (2026-09-14): say what broke. */
      try { if (window.console && console.error) console.error('[hc router] falling back to a full load:', err); } catch(e){}
      hcSetLoading(false);
      if(page) page.classList.remove('hc-loading');
      if(wlBodyPre) wlBodyPre.classList.remove('hc-wl-busy');
      if(IS_FULL_EMBED){
        hcToast(<?= json_encode((string)$__t('load_failed', 'Could not load that help page.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
        return;
      }
      hcToast(<?= json_encode((string)$__t('load_failed_fallback', 'Could not load that help page. Opening the full page instead.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
      window.setTimeout(function(){ window.location.href = url; }, 450);
    });
}

/* Intercept internal link clicks */
document.addEventListener('click', function(e){
  /* PHASE10H_2026-08-11 — a feature that consumed the click (expand-in-place,
   * the category paginator) calls preventDefault; the router must treat that as
   * "handled" and stand down. Without this the router still fetched and swapped
   * the page AFTER the in-place behaviour ran — the expand looked fine for a
   * beat and then the browse page replaced it. */
  if (e.defaultPrevented) return;
  var target = e.target;
  while(target && target !== document){
    if(target.tagName === 'A') break;
    target = target.parentNode;
  }
  if(!target || target.tagName !== 'A') return;
  if(target.getAttribute('target') === '_blank') return;
  var href = target.getAttribute('href');
  if(!href) return;
  if(href.indexOf('#') === 0) return;
  var absUrl, helpUrl;
  try {
    absUrl = new URL(href, window.location.href);
    helpUrl = new URL(HELP_BASE, window.location.href);
  } catch(err) { return; }
  if(IS_FULL_EMBED){
    if(absUrl.origin !== helpUrl.origin || absUrl.pathname !== helpUrl.pathname) return;
  } else if(target.hostname && target.hostname !== window.location.hostname) {
    return;
  }
  /* Only intercept help.php links or ?-rooted links */
  var isHelpLink = href.indexOf('help.php') >= 0 || href.indexOf(HELP_BASE) === 0 || href.charAt(0) === '?' || (IS_FULL_EMBED && absUrl.pathname === helpUrl.pathname);
  if(!isHelpLink) return;
  if(absUrl.search.indexOf('_hcajax') >= 0) return;
  if(absUrl.search.indexOf('sitemap=') >= 0) return;
  /* PHASE_HC_I18N — a language change MUST be a real page load, never an AJAX swap.
   * The router replaces #hc-page only, and everything a language actually changes
   * lives OUTSIDE it: <html lang>, dir="rtl" (the whole mirroring), the nav, and the
   * picker's own badge. Swapping just the content left the trigger showing the
   * language you came FROM until you refreshed. Let the browser navigate. */
  if(target.classList && (target.classList.contains('hc-lang-item') || target.classList.contains('hc-wl-lang-item'))) return;
  e.preventDefault();
  hcNav(absUrl.href);
}, false);

/* popstate — browser back/forward */
if(!IS_FULL_EMBED){
  window.addEventListener('popstate', function(e){
    if(e.state && e.state.hcUrl){
      hcNav(e.state.hcUrl);
    } else {
      window.location.href = window.location.href;
    }
  });

  /* Seed initial state */
  history.replaceState({hcUrl:window.location.href}, document.title, window.location.href);
}

/* Keyboard shortcuts */
document.addEventListener('keydown', function(e){
  var tag = document.activeElement && document.activeElement.tagName;
  var inInput = tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT';

  /* Cmd/Ctrl+K */
  if((e.metaKey||e.ctrlKey) && e.key==='k'){
    e.preventDefault();
    var inp = qs('#hc-hero-input');
    if(inp){ inp.focus(); inp.select(); }
    return;
  }
  /* / focuses search when not in input */
  if(e.key==='/' && !inInput && !e.metaKey && !e.ctrlKey){
    var inp2 = qs('#hc-hero-input');
    if(inp2){ e.preventDefault(); inp2.focus(); inp2.select(); }
    return;
  }
  /* Escape closes any open autocomplete */
  if(e.key==='Escape'){
    qsa('.hc-suggest').forEach(function(el){ el.hidden=true; el.innerHTML=''; });
  }
});

/* Embed auto-height (for full-page iframe embeds) */
function hcPostEmbedHeight(){
  if(!window.parent || window.parent === window) return;
  var h1 = document.documentElement ? document.documentElement.scrollHeight : 0;
  var h2 = document.body ? document.body.scrollHeight : 0;
  var h = Math.max(h1,h2,0);
  if(h > 0){
    try { window.parent.postMessage({type:'opsiq_help_height', height:h, site_key:SITE_KEY}, '*'); } catch(e){}
  }
}
if(window.parent && window.parent !== window){
  window.addEventListener('message', function(ev){
    if (!ev || ev.source !== window.parent) return; /* AUDIT 2026-09-07 (lane 04, #274) */
    var d = ev && ev.data ? ev.data : {};
    if(d && d.type === 'opsiq_help_request_height') hcPostEmbedHeight();
  });
  var _hcHeightTick = null;
  var hcScheduleEmbedHeight = function(){
    if(_hcHeightTick) return;
    _hcHeightTick = setTimeout(function(){ _hcHeightTick = null; hcPostEmbedHeight(); }, 60);
  };
  window.addEventListener('load', hcPostEmbedHeight);
  window.addEventListener('resize', hcScheduleEmbedHeight);
  document.addEventListener('readystatechange', hcScheduleEmbedHeight);
  if(window.MutationObserver){
    var _hcMo = new MutationObserver(hcScheduleEmbedHeight);
    _hcMo.observe(document.documentElement || document.body, {childList:true, subtree:true, characterData:true, attributes:false});
  }
}

<?php if ($_i18nOn): /* Only when the site is actually multilingual — an untouched
   Help Center must not carry a language API it can never use. */ ?>
/* PHASE_HC_I18N — the public language API. Mirrors OpsIQHelpTheme so an operator
 * with no nav can drop `OpsIQHelpLang.set('fr')` on any element of their own.
 * The choice is a cookie (the server reads it on the next request, so links stay
 * clean) AND a ?lang= on the URL being loaded, so a shared link keeps its language.
 * A real navigation, not a live re-render: article CONTENT is translated
 * server-side, so the page has to be fetched again to change language. */
window.OpsIQHelpLang = {
  get: function(){ return <?= json_encode($_locale) ?>; },
  available: function(){ return <?= json_encode(array_values($_i18nLocales)) ?>; },
  set: function(code){
    code = String(code || '').toLowerCase().replace(/[^a-z-]/g, '');
    if (!code || this.available().indexOf(code) < 0) return;
    if (code === this.get()) return;
    try { localStorage.setItem('hc_lang', code); } catch(e){}   // PHASE_HC_I18N_SYNC
    try { document.cookie = 'hc_lang=' + code + ';path=/;max-age=31536000;SameSite=Lax'; } catch(e){}
    var u = new URL(window.location.href);
    /* The source language rides on a clean URL — no ?lang= to share around. */
    if (code === <?= json_encode($_i18nSource) ?>) u.searchParams.delete('lang');
    else u.searchParams.set('lang', code);
    window.location.href = u.toString();
  }
};
<?php if (!empty($_widget)): /* PHASE_HC_I18N — the widget panel's header picker.
   This messaging MUST live here and not in the dark-mode boot script: it was there
   first, so with dark mode OFF the frame never reported its language and the header
   badge sat on the default while the panel showed something else. Theme and language
   are independent features and must not gate each other. */ ?>
/* The bar is on the customer's page, cross-origin, so it asks and we answer.
   Safety: only our direct parent, and set() validates the code against the
   languages this site actually offers, so a host page cannot send us anywhere. */
window.addEventListener('message', function(e){
  if (e.source !== window.parent) return;
  var d = e.data;
  if (!d || d.type !== 'opsiq-help-lang') return;
  window.OpsIQHelpLang.set(d.set);
});
/* Report on boot. A language change is a real navigation, so the new page's boot
   fires this again — the header badge always follows the frame, never a guess. */
/* `view` rides along so the loader can put the panel back exactly here after the host
   navigates. By now the theme param has been stripped above, so this is a clean URL. */
try { window.parent.postMessage({type:'opsiq-help-lang-state', lang: <?= json_encode($_locale) ?>, view: location.href}, '*'); } catch(e){}
<?php endif; ?>
<?php endif; ?>

/* QUICK-LINK TABS — ONE HIGHLIGHT.
 * The rail entries are real anchors to their panels, so with scripting off a tab
 * still jumps to (and reveals) its panel. With scripting on: clicking a tab
 * opens its panel and moves the highlight, and opening a panel by its own bar
 * moves the highlight too — the rail must never claim one tab while the reader
 * is looking at another's list. Panels stay independent; nothing is closed.
 * PHASE0_2026-08-06 — was a boot-once IIFE over DOM inside #hc-page; after any
 * soft nav the highlight died (audit item 4.6). Now re-bootable from
 * hcInitView(), idempotent per wrap via dataset flag. */
function hcInitQuickTabs(){
  document.querySelectorAll('[data-hce-tabs]').forEach(function(wrap){
    if (wrap.dataset.tabsBound === '1') return;
    wrap.dataset.tabsBound = '1';
    var tabs = wrap.querySelectorAll('[data-hce-tab]');
    function mark(i){ tabs.forEach(function(t){ t.classList.toggle('is-on', t.getAttribute('data-hce-tab') === String(i)); }); }
    tabs.forEach(function(t){
      t.addEventListener('click', function(e){
        var i = t.getAttribute('data-hce-tab');
        var p = wrap.querySelector('[data-hce-panel="' + i + '"]');
        if (!p) return;
        e.preventDefault();
        p.open = true;
        mark(i);
        p.scrollIntoView({block:'nearest', behavior:'smooth'});
      });
    });
    wrap.querySelectorAll('[data-hce-panel]').forEach(function(p){
      p.addEventListener('toggle', function(){ if (p.open) mark(p.getAttribute('data-hce-panel')); });
    });
  });
}

/* Boot */
hcInitView();
hcPostEmbedHeight();

})();
</script>
<?php /* Widget mode is a 420px nested panel: third-party embeds injected by custom JS
       (chat bubble, cookie banner, analytics overlays) belong to the host page, not
       inside the help panel. Custom JS still runs on the public page and on ?embed=1. */ ?>
<?php
/* Supply the Portal customer to the UNCHANGED Client Chat widget through its
 * documented host-page token contract. This is emitted before operator custom
 * JavaScript, so a widget loader placed there sees the token on its first boot. */
$__hcWidgetMe = null;
$__hcWidgetToken = '';
if (empty($_widget)) {
    try {
        if (!function_exists('opsiq_portal_identity_for_workspace')) {
            $__f = $_opsiqRoot . '/opsiq/opsiq.portal_identity.php';
            if (is_file($__f)) require_once $__f;
        }
        if (function_exists('opsiq_portal_identity_for_workspace')) {
            $__hcWidgetMe = opsiq_portal_identity_for_workspace((string)$_siteKey);
        }
        if (is_array($__hcWidgetMe) && function_exists('opsiq_portal_mint_widget_identity_token_for_site')) {
            $__hcWidgetToken = opsiq_portal_mint_widget_identity_token_for_site((string)$_siteKey, $__hcWidgetMe);
        }
    } catch (\Throwable $e) { $__hcWidgetMe = null; $__hcWidgetToken = ''; }
}
?>
<?php if (empty($_widget)): ?>
<script id="hc-client-chat-identity">
(function(){
  /* 2026-08-24 — the refresh URL is emitted UNCONDITIONALLY (not only when the
   * visitor is signed in at render time). The widget's built-in fallback is
   * /opsiq/identity_refresh.php on the PAGE's origin, which does not exist on a
   * proxied custom-domain help centre (only /, /hc and /opsiq assets route
   * through the tenant's proxy) — the refresh call hung and an expired 8h token
   * could never be renewed. This help-centre route (_hcajax + _widget_identity)
   * is same-origin on BOTH the main domain and every custom domain, and answers
   * 401 login_required cleanly when no session exists. */
  var token = <?= json_encode($__hcWidgetToken, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var customer = <?= json_encode(is_array($__hcWidgetMe) ? array_merge($__hcWidgetMe, [
      'id' => (!empty($__hcWidgetMe['external_id']) && ctype_digit((string)$__hcWidgetMe['external_id']))
          ? (int)$__hcWidgetMe['external_id'] : (int)($__hcWidgetMe['id'] ?? 0),
  ]) : null, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var refreshUrl = <?= json_encode(hc_u('_hcajax=1&_widget_identity=1'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  window.opsiqConfig = window.opsiqConfig || {};
  window.opsiqConfig.identityRefreshUrl = refreshUrl;
  /* Client Chat already supports host overrides through clientWidget. Keep
   * Portal/HC identity entirely on this public surface; the chat runtime and
   * its thread lifecycle remain untouched. */
  window.opsiqConfig.clientWidget = Object.assign({}, window.opsiqConfig.clientWidget || {}, {
    identityRefreshUrl: refreshUrl
  });
  if (token && customer) {
    window.opsiqConfig.identityToken = token;
    window.opsiqConfig.user = customer;
    window.opsiqConfig.clientWidget.identityToken = token;
    window.opsiqConfig.clientWidget.user = customer;
  }
})();
</script>
<?php endif; ?>
<?php if ($customJs !== '' && empty($_widget)): ?>
<script id="hc-custom-js">
(function(){
  try {
<?= $customJs ?>
  } catch (e) {
    console.error('OpsIQ custom JS error', e);
  }
})();
</script>
<?php endif; ?>
<?php /* ── THE ASSISTANT MOUNTS ON EVERY HELP CENTRE SURFACE ────────────────────────
 * This section used to sit INSIDE the `$__helpUnified && $__portalDesignBundle`
 * branch, which only runs on a portal-matched /hc host. That is the whole reason
 * the launcher "only ever appeared on the portal": on the help centre's own domain
 * the code never executed at all. It lives out here now, after the page and before
 * </body>, so every surface reaches it and the guards inside decide. */ ?>
<?php /* ═══ PHASE_PORTAL_ASSISTANT ON /hc ══════════════════════════════════════════════════════
 *
 * The assistant answers from this workspace's KB and opens a request when it cannot — which is
 * exactly what somebody reading the Help Centre wants, yet it only ever appeared on the portal.
 * The reason was structural, not deliberate: it lived inside portal.php's IIFE and its CSS inside
 * portal-core.css, and this surface shares neither. Both now live in ONE module
 * (opsiq/assets/portal-assistant.{css,js}) that both surfaces load, so there is no second copy.
 *
 * What differs here is only the host adapter: this page has no pxGo(), no knowledge router and no
 * realtime channel, so "submit a request" LEAVES for the portal (carrying the transcript through
 * sessionStorage, which the portal's compose page reads), articles open at their own URLs, and the
 * live chat falls back to the module's poll. Config is the SAME published design block the portal
 * reads, so the Studio stays the single place it is configured. */ ?>
<?php
/* ── WHICH HOSTS MOUNT IT (owner: bring the chat assistance back) ──────────────
 *
 *   · Matched /hc (a portal-help host): the config is the PORTAL'S OWN, the same
 *     published design block, read from $__portalDesignBundle. That is the portal
 *     "taking it to /hc"; nothing extra is mounted, so it can never double.
 *   · Standalone help centre hosts (kb.nabtech.co and friends): the SAME published
 *     block, loaded directly. Without this the launcher appeared on /hc and nowhere
 *     else, which is why it was missing from the help centre's own domain.
 *   · Never on the widget or an embed: a launcher inside the 420px nested panel, or
 *     inside somebody else's iframe, belongs to the host page. Same rule custom JS
 *     follows.
 *
 * The Studio remains the single place the assistant's 22 controls are configured.
 * `hc_assistant_enabled` is the help centre's OWN switch on top of that, so this
 * surface can be turned off without touching the portal. Default on: with it off by
 * default, restoring the mount would have changed nothing visible. */
$__hcAsst = null;
if (empty($_widget) && empty($_embed)) {
    $__hcCfg = hc_assistant_config((array)$_settings);
    if (!empty($__hcCfg['enabled'])) $__hcAsst = $__hcCfg;
}
/* The launcher's "submit a request" needs somewhere to go. Without a portal address the assistant
 * can still answer, so this only disables the handoff, never the assistant. */
$__hcAsstPortal = '';
if ($__hcAsst && !function_exists('opsiq_portal_public_base')
    && is_file($_opsiqRoot . '/opsiq/opsiq.portal_experience.php')) {
    try { require_once $_opsiqRoot . '/opsiq/opsiq.portal_experience.php'; } catch (\Throwable $e) {}
}
if ($__hcAsst && function_exists('opsiq_portal_public_base')) {
    try { $__hcAsstPortal = (string)opsiq_portal_public_base((string)$_siteKey); } catch (\Throwable $e) { $__hcAsstPortal = ''; }
}
?>
<?php if (is_array($__hcAsst) && !empty($__hcAsst['enabled'])): ?>
<?php /* THROUGH hc_asset_url(). This host is a PROXIED subdomain where every path
   routes to help.php, so a root-relative asset URL comes back as the help centre
   PAGE with content-type text/html — and with nosniff the browser refuses to run it. */ ?>
<link rel="stylesheet" href="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/portal-assistant.css')) ?>">
<?php /* TOKEN BRIDGE + STACKING. The module is a PORTAL component: it paints from
   --card / --ink / --line / --accent, none of which exist here, and an undefined var
   is invalid AT USE TIME, so each dropped its whole declaration and the launcher
   painted transparent in both themes. It also ships at z-index 75, under this
   page's 200 header. Both fixed here. */ ?>
<style id="px-ask-bridge">
:root{
  --accent: var(--brand,#6c5ce7);
  --pxa:    var(--brand,#6c5ce7);
  --card:   var(--card-bg,#fff);
  --bg:     var(--body-bg,#f7f8fc);
  --line:   var(--card-border,rgba(15,23,42,.12));
  --ink:    var(--text-primary,#0f172a);
  --ink-2:  var(--text-secondary,#475569);
  --ink-3:  var(--text-muted,#94a3b8);
}
html[data-theme="dark"]{
  --card:  var(--hc-d-card,#151b25);
  --bg:    var(--hc-d-bg,#0b0f16);
  --line:  var(--hc-d-line,#2b3646);
  --ink:   var(--hc-d-ink,#e8edf5);
  --ink-2: var(--hc-d-ink-2,#a9b4c4);
  --ink-3: var(--hc-d-ink-3,#71809a);
}
.pxa-launch,.px-fab{z-index:250}
.pxa-panel,.pxa-sheet,.pxa-modal,[class*="pxa-panel"]{z-index:251}
</style>
<div id="px-ask-root"></div>
<script src="<?= hc_esc(hc_versioned_asset_url($_opsiqRoot, '/opsiq/assets/portal-assistant.js')) ?>"></script>
<script>(function(){
  if(!window.OpsIQAssistant) return;
  <?php
  /* The assistant module is shared with the portal, so its words live in ONE catalogue: the
   * portal's (opsiq.portal_i18n.php, 40 languages). Reading the same rows here means the panel
   * says the same thing on /hc and /portal and there is no second copy to drift. */
  $__asstI18n = ['create' => (string)$__t('assistant_create', 'Submit a request')];
  if (!function_exists('opsiq_portal_i18n_strings') && is_file(__DIR__ . '/opsiq/opsiq.portal_i18n.php')) require_once __DIR__ . '/opsiq/opsiq.portal_i18n.php';
  if (function_exists('opsiq_portal_i18n_strings')) {
      foreach (opsiq_portal_i18n_strings((string)$_locale) as $__k => $__v) {
          if (is_string($__v) && $__v !== '' && (str_starts_with((string)$__k, 'asst_') || in_array($__k, ['close', 'kb_fb_send', 'chat_connected', 'support', 'chat_closed'], true))) $__asstI18n[$__k] = $__v;
      }
  }
  ?>
  var HC_ASST_I18N = <?= json_encode($__asstI18n, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var CFG = <?= json_encode($__hcAsst, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var SK   = <?= json_encode((string)($_siteKey ?? '')) ?>;
  var PBASE= <?= json_encode($__hcAsstPortal) ?>;
  /* $_locale is what this page was RENDERED in — the tiebreaker the server uses when a question is
     too short to detect a language from. */
  var LANG = <?= json_encode((string)($_locale ?? 'en')) ?>;
  var SEP  = (PBASE && PBASE.indexOf('?')<0) ? '?' : '&';
  var HCASST = <?= json_encode(hc_u("_hcasst=")) ?>;

  /* ── "TALK TO A PERSON" GOES TO YOUR LIVE CHAT ────────────────────────────────────────────
   * The module ships its own ticket-backed polled chat, and its own comment says it is
   * "separate from the client chat widget". On the help centre that is the wrong answer: this
   * site already has a live chat, and a visitor who asks for a person should land in THAT
   * conversation, not a second one nobody is watching.
   *
   * So the button is intercepted here rather than the module being edited — it stays the shared
   * module, and the host decides what "a person" means:
   *   1. the client chat widget, if it is on this page. Its real launcher lives inside
   *      #opsiq-widget-host's open shadow root (the outer div is pointer-events:none), which is
   *      the same path the module itself walks to stack clear of it;
   *   2. otherwise the contact page from Labels & Typography. No widget and no contact URL means
   *      the button is left alone and the module's own chat still answers, so this can never
   *      leave the visitor with a dead control.
   * Capture phase, because the module binds its handler to the button itself — stopping the event
   * on the way down is what keeps its chat from starting underneath ours. */
  var HCCONTACT = <?= json_encode((string)($txtContactUrl ?? '')) ?>;
  function hcOpenClientChat(){
    var host = document.getElementById('opsiq-widget-host');
    var root = host && (host.shadowRoot || host);
    if (!root) return false;
    var fab = (root.getElementById && root.getElementById('opsiq-client-fab-v2'))
           || root.querySelector('#opsiq-client-fab-v2')
           || root.querySelector('.opsiq-client-fab');
    if (!fab) return false;
    fab.click();
    return true;
  }
  document.addEventListener('click', function(e){
    var b = e.target && e.target.closest ? e.target.closest('.pxa-act-primary') : null;
    if (!b || !b.closest('.pxa-deflect')) return;
    if (hcOpenClientChat()) {
      e.preventDefault(); e.stopPropagation();
      return;
    }
    if (HCCONTACT) {
      e.preventDefault(); e.stopPropagation();
      window.location.href = HCCONTACT;
    }
    /* Neither available: let the module do what it always did. */
  }, true);

  /* ── THE TOKEN CONTRACT, IN FULL ─────────────────────────────────────────────────────────
   * portal_ask / portal_chat_start / portal_chat_send are classified browser mutations
   * (PublicMutationGuard): the server requires a verified same-site Origin AND the
   * synchroniser token, echoed from the opsiq_csrf cookie as X-OpsIQ-CSRF. This adapter was
   * a partial copy of portal.php's fetch helper and carried NEITHER half, so from the day
   * the guard landed (2026-08-26) every question on every Help Centre answered
   * "Invalid or missing CSRF token." A guest also starts with no session and so no token
   * (PHASE_NO_ORPHAN_SESSION), which is why the mint-on-demand step exists: the token is
   * fetched only after a request has actually been refused for want of one, once, and then
   * the call is retried. Same shape as portal.php:1665 and careers.php; the only difference
   * is the endpoint, which stays this page's own proxy so the token lands in THIS surface's
   * session on a proxied host too. */
  var HC_NET_ERR = <?= json_encode((string)$__t('network_error', 'Network error. Please try again.'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  function hcAsstApi(action, data, _csrfRetried){
    var fd = new FormData();
    Object.keys(data||{}).forEach(function(k){ fd.append(k, data[k]); });
    if (SK && !fd.has('site_key')) fd.append('site_key', SK);
    var headers = {'X-Requested-With':'XMLHttpRequest','Accept':'application/json'};
    try {
      var csrf = document.cookie.match(/(?:^|;\s*)opsiq_csrf=([^;]+)/);
      if (csrf && csrf[1]) headers['X-OpsIQ-CSRF'] = decodeURIComponent(csrf[1]);
    } catch(e) {}
    /* Same origin, always: on a proxied host the real ajax path returns this
       page as HTML, so help.php answers for the assistant itself. */
    return fetch(HCASST + encodeURIComponent(action), {
      method:'POST', body:fd, credentials:'same-origin', headers:headers
    }).then(function(r){ return r.json(); })
      .then(function(res){
        if (!res || res.csrf_required !== true || _csrfRetried) return res;
        return hcAsstApi('portal_csrf_token', {}, true).then(function(t){
          if (!t || t.success !== true) return res;      /* keep the original refusal */
          return hcAsstApi(action, data, true);
        });
      })
      .catch(function(){ return {success:false, error: HC_NET_ERR}; });
  }

  window.OpsIQAssistant.init({
    /* Same public endpoint the portal calls, with the workspace pinned exactly as this page's own
       calls pin it — /hc can be reached on a host that does not imply the workspace. */
    api: hcAsstApi,
    locale: function(){ return LANG || ''; },
    /* PHASE_HC_ASSISTANT_I18N_2026-08-15 — a REAL translation hook.
       This returned the English fallback for every key, while the portal's identical
       module gets `(PXI18N && PXI18N[k]) || fb` (portal.php:2966). The module is
       shared, so the help centre was the only surface where its chrome could not
       translate. One key is routed through it today (`create`); the map is here so
       the next one is not another English trapdoor. */
    t: function(k, fb){ return (HC_ASST_I18N && HC_ASST_I18N[k]) || fb; },
    /* Help Centre articles are pages on THIS surface, so they open in place — a new tab would be
       the odd choice here, unlike on the portal where the reader is mid-task. */
    articleHref: function(a){
      a = a || {};
      if (a.slug) return '?article=' + encodeURIComponent(a.slug);
      return a.url || '#';
    },
    articleNewTab: function(){ return false; },
    /* No compose form on this surface: carry the transcript over and let the portal prefill. */
    submitRequest: PBASE ? function(pre){
      try{ var handoff=pre||{}; handoff.source='help-center'; sessionStorage.setItem('px_ask_handoff', JSON.stringify(handoff)); }catch(e){}
      location.href = PBASE + SEP + 'p=new';
    } : null,
    realtimeSync: null
  });
  window.OpsIQAssistant.mount(CFG);
})();</script>
<?php endif; ?>
<?php if ($__helpUnified && $darkEnabled): ?>
<?php /* PORTAL_COLOURS_HC_FOOTER_2026-08-23: final semantic guard for the
        Portal-unified /HC surface only. The Help Center keeps its existing
        dark-mode engine; Portal supplies the colour tokens. This covers either
        footer source (Portal or Help Center), including Bring Your Own markup. */ ?>
<style id="hc-portal-footer-dark-guard">
html[data-theme="dark"] :is(#px-footer .pfoot,#hc-foot){
  color-scheme:dark;
  --footer-bg:var(--portal-dark-bg,#0b1220);
  --text-primary:var(--portal-dark-ink,#e8ecf6);
  --text-secondary:color-mix(in srgb,var(--portal-dark-ink,#e8ecf6) 78%,transparent);
  --text-muted:color-mix(in srgb,var(--portal-dark-ink,#e8ecf6) 66%,transparent);
  background:linear-gradient(160deg,var(--portal-dark-card,#141c2e),var(--portal-dark-bg,#0b1220))!important;
  color:var(--portal-dark-ink,#e8ecf6)!important;
  border-color:var(--portal-dark-line,color-mix(in srgb,#e8ecf6 15%,transparent))!important
}
/* ⚠ background-image IS NOT A FILL TO BE CLEARED — IT IS USUALLY THE OPERATOR'S IMAGE.
   These rules carried `background-image:none!important` and wiped every logo, icon and
   payment badge placed through CSS in a custom nav or footer. Only background-COLOR
   makes the white slabs this pass exists to remove. A light gradient survives as a
   result; a missing logo is the worse failure, and the one an operator cannot work
   around. Reported 2026-08-31 on the portal, and true here for exactly as long. */
html[data-theme="dark"] :is(#px-footer .pfoot-custom,#hc-foot.hc-foot-custom) :where(*){
  background-color:transparent!important;
  color:inherit!important;border-color:var(--portal-dark-line,color-mix(in srgb,#e8ecf6 15%,transparent))!important;
  box-shadow:none!important;text-shadow:none!important
}
html[data-theme="dark"] :is(#px-footer .pfoot-custom,#hc-foot.hc-foot-custom) :where(*::before,*::after){
  background-color:transparent!important;
  border-color:var(--portal-dark-line,color-mix(in srgb,#e8ecf6 15%,transparent))!important;box-shadow:none!important
}
html[data-theme="dark"] :is(#px-footer .pfoot-custom,#hc-foot.hc-foot-custom) :where(a){color:var(--portal-dark-ink,#e8ecf6)!important}
html[data-theme="dark"] :is(#px-footer .pfoot-custom,#hc-foot.hc-foot-custom) :where(a:hover,a:focus-visible){color:var(--accent,#6c5ce7)!important}
html[data-theme="dark"] :is(#px-footer .pfoot-custom,#hc-foot.hc-foot-custom) :where(input,select,textarea,button){
  background:var(--portal-dark-card,#141c2e)!important;color:var(--portal-dark-ink,#e8ecf6)!important;
  border-color:var(--portal-dark-line,color-mix(in srgb,#e8ecf6 15%,transparent))!important
}
</style>
<?php endif; ?>
<?php /* HC_CUSTOM_CSS_LAST_2026-09-12 — the operator stylesheet is literally the
        final stylesheet in the document, including on Portal-unified pages. It was
        previously followed by the footer dark guard, contradicting the editor's
        "custom CSS wins" contract. Sanitised at read time ($customCss strips <style>
        tags and neutralises </style) and emitted exactly once. */ ?>
<?php if ($customCss !== ''): ?>
<style id="hc-custom-css">
<?= $customCss ?>
</style>
<?php endif; ?>
<?php if ($_embed): ?>
<?php
  /* The operator's own help-center host, normalised exactly as the canonical block above. */
  $__embedOwnHost = strtolower(trim((string)($_settings['custom_domain'] ?? '')));
  $__embedOwnHost = (string)preg_replace('~^https?://~', '', $__embedOwnHost);
  $__embedOwnHost = trim(explode('/', $__embedOwnHost)[0]);
  $__embedOwnHost = (string)preg_replace('/[^a-z0-9.\-:]/', '', $__embedOwnHost);
?>
<script>
/* PHASE_HC_EMBED_LINKS_2026-09-02 — THE OWN-DOMAIN LINK MUST STAY IN THE PANEL.
 *
 * The widget renders this page inside a slide-in iframe served from the PLATFORM host. The
 * operator's chrome links to their help center by its OWN domain (an absolute url), so a
 * click sent the iframe cross-origin — and `.htaccess` sets `X-Frame-Options: SAMEORIGIN`,
 * so the browser refused and the panel showed:
 *
 *     hc.opsiqai.com refused to connect — ERR_BLOCKED_BY_RESPONSE
 *
 * (help.php's embed branch DOES drop that header, but Apache's non-`always` `Header set`
 * re-adds it after PHP runs — measured: the embed response carries both `frame-ancestors *`
 * and the XFO. So the fix cannot live in the header layer.)
 *
 * It must not open a new tab either (owner, 2026-09-02: "not in a new tab") — the panel is
 * where the visitor is reading. Both hosts serve the SAME help center, so the link is simply
 * re-pointed at this origin, carrying the destination's own query across and preserving the
 * embed parameters that keep the panel a panel. Same origin, no framing question at all.
 */
(function(){
  var OWN = <?= json_encode($__embedOwnHost, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  if (!OWN) return;
  var KEEP = ['embed','widget','site_key','side','hcfull'];
  var SKIP = /^(#|javascript:|mailto:|tel:|sms:)/i;
  document.addEventListener('click', function(ev){
    if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
    var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
    if (!a) return;
    var raw = a.getAttribute('href') || '';
    if (!raw || SKIP.test(raw) || a.hasAttribute('download')) return;
    var u; try { u = new URL(a.href, location.href); } catch (e) { return; }
    /* Two ways a link leaves the widget view, and the panel errors either way:
       (a) it names the operator's OWN help-center host — cross-origin, and X-Frame-Options
           refuses to let the panel frame it;
       (b) it is this same help center but DROPS the embed flag, so the full page loads
           inside a ~470px panel. Owner, 2026-09-02: "many of them drop it, that's why they
           show error when you click them." Neither can be fixed by writing each link
           correctly — the operator's own chrome supplies absolute urls we do not author. */
    var isOwn  = (u.host === OWN);
    var isHere = (u.origin === location.origin && u.pathname === location.pathname);
    if (!isOwn && !isHere) return;           /* not a help-center route — leave it alone */
    /* PHASE_HC_WIDGET_LOOK (2026-09-14) — a SAME-ORIGIN link that already carries the embed
       flag is correct whatever its path: the frame's own links are built on /help while the
       frame is served as /help.php, so `isHere` was false for every one of them and this
       handler re-pointed each click into a FULL page load — the hero "blinked" on every step
       and the router (which swaps the body only) never got the click. */
    if (u.searchParams.get('embed') && u.origin === location.origin) return;   /* already correct */

    var here = new URL(location.href);
    var next = new URL(location.pathname, location.origin);
    KEEP.forEach(function(k){ var v = here.searchParams.get(k); if (v !== null) next.searchParams.set(k, v); });
    u.searchParams.forEach(function(v, k){ if (KEEP.indexOf(k) < 0) next.searchParams.set(k, v); });
    ev.preventDefault();
    location.href = next.toString();         /* same origin, embed intact — stays a panel */
  }, true);
})();
</script>
<?php endif; ?>
</body>
</html>
