From a0f2b860a6535679a66302da2a3654976d12c589 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Mon, 25 May 2026 13:54:19 -0400 Subject: [PATCH] fix(1438): paint chromeless `` surfaces in dark mode on first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kickit page (and three sister surfaces) shipped no anti-FOUC bootloader, so a dark-mode operator who right-clicked a player on the public Servers page → "Kick player" landed on a stark-white full-page kickit document — the templates don't pull `theme.js`, so without the inline `` block from each template's ``, + * collapses runs of whitespace, and asserts all five normalized + * bodies are equal. + * + * The bootloader is intentionally MORE defensive than `theme.js`'s + * `applyTheme(currentTheme())`: it ships an explicit + * `window.matchMedia &&` null check, wraps the entire body in + * `try/catch` (not just the localStorage write), and uses + * `classList.add` instead of `classList.toggle` (the bootloader + * never needs to remove the class — `:root` defaults to light). The + * extra defensiveness exists because theme.js failing is recoverable + * (the next render gets the right theme) but the bootloader failing + * IS the FOUC bug. So the bootloader is *semantically* equivalent to + * theme.js's resolution, not byte-identical. Documented in AGENTS.md + * Conventions "Anti-FOUC theme bootloader". + * + * Why static-grep this and not the runtime + * ---------------------------------------- + * + * The runtime gates exist — `web/tests/e2e/specs/flows/theme-fouc.spec.ts` + * (chrome) plus `web/tests/e2e/specs/flows/iframe-anti-fouc.spec.ts` + * (kickit + blockit) — both stall the appropriate scripts and + * assert `` appears at the right moment. But e2e + * gates only catch the regression once a real browser navigates + * there, and `page_uploadfile.tpl` (popup window opener handshake) + * + `updater.tpl` (one-shot upgrade page that needs pending + * migrations to render) are awkward to drive from Playwright. + * Static grep here catches: + * + * - A future "simplification" that removes the bootloader from one + * template "because theme.js does it anyway" (the exact pre-#1367 + * reasoning the panel-chrome bootloader exists to defeat). + * - A drift edit that updates the `'sbpp-theme'` key in theme.js + + * `core/header.tpl` but forgets the iframe / upload / updater + * templates. + * - A reviewer who removes any bootloader copy "for consistency + * with the install wizard" without realising the install wizard's + * exemption is documented separately (no `theme.js` / no toggle + * / no logged-in user). + * - A new chromeless `` surface added without the bootloader + * (the test enumerates the known sites; adding a sixth requires + * a paired test edit, which forces the conversation about whether + * the new surface should ship the bootloader too). + * + * Pure file scanning — extends `PHPUnit\Framework\TestCase` directly + * (no DB / session / Smarty bring-up). Sister of + * `DeadJsCallSitesTest`; same per-file forbidden-substring shape, + * inverted into per-required-fragment assertions. + */ +final class IframeChromeAntiFoucBootloaderTest extends TestCase +{ + /** + * The four load-bearing fragments of the bootloader. We check each + * separately so a regression message names the specific drift + * (e.g. "lost the matchMedia branch" vs. "renamed THEME_KEY") rather + * than a wholesale "your bootloader is wrong" diff. + * + * Fragments are intentionally tight — they fail loudly on a + * cosmetic edit (whitespace, single vs. double quote) so the + * semantic-equivalence contract with `theme.js` / `core/header.tpl` + * is actually enforced. If a maintainer needs to tweak whitespace, + * the fix is to update this test in the same PR, not to loosen + * the literal. + * + * @return list Pairs of [substring, why]. + */ + private static function requiredFragments(): array + { + return [ + [ + "localStorage.getItem('sbpp-theme')", + "must read the same THEME_KEY ('sbpp-theme') as `theme.js` — drift here " + . "silently desyncs first paint from theme.js's boot-time `applyTheme(currentTheme())`", + ], + [ + "|| 'system'", + "must fall back to the same default ('system') as `theme.js` — a different " + . "default makes a first-time visitor see light/dark inconsistently across templates", + ], + [ + "matchMedia('(prefers-color-scheme: dark)').matches", + "must consult the OS preference for the 'system' arm — without the matchMedia " + . "branch, system-mode operators on dark OS always paint light first", + ], + [ + "document.documentElement.classList.add('dark')", + "must add (NOT replace) the `dark` class on `` — :root defaults to light " + . "so removing isn't needed; using `.className =` or `.classList.toggle` would " + . "stomp other classes the chrome (or a fork) may have added", + ], + ]; + } + + /** + * The five template surfaces that ship a self-contained `` + * and therefore need their own bootloader copy. The install + * wizard's templates under `install/` are deliberately NOT in + * this list — see AGENTS.md "Install wizard" for the documented + * exemption (no logged-in user, no `theme.js`, no toggle, no + * persisted theme). The negative is enforced by + * `testInstallWizardTemplatesDoNotCarryBootloader`. + * + * @return list + */ + private static function templatesRequiringBootloader(): array + { + return [ + 'core/header.tpl', + 'page_kickit.tpl', + 'page_blockit.tpl', + 'page_uploadfile.tpl', + 'updater.tpl', + ]; + } + + private static function loadTemplate(string $relativePath): string + { + $path = ROOT . 'themes/default/' . $relativePath; + // Don't suppress with `@` — let PHPUnit surface the warning so a + // genuinely missing file gets diagnostic info beyond our hand-built + // failure message. The fallback below still fires if `file_get_contents` + // returns false rather than warning (older PHP / userland streams). + $contents = file_get_contents($path); + if ($contents === false) { + self::fail( + "Could not read $path — file moved or test bootstrap broke? " + . "If you renamed the template, update IframeChromeAntiFoucBootloaderTest::templatesRequiringBootloader() in the same PR." + ); + } + return $contents; + } + + /** + * Strip Smarty `{* ... *}` / `-{* ... *}-` comment blocks (greedy, + * multiline) so my own explanatory comments at the top of each + * template (which legitimately discuss the bootloader by name) + * don't false-match the required-fragment grep. Matches both + * delimiter pairs because `core/header.tpl` uses the default + * `{` `}` and the iframe templates use `-{` `}-`. + */ + private static function stripSmartyComments(string $contents): string + { + // {* ... *} for default-delimiter templates. + $contents = (string) preg_replace('/\{\*.*?\*\}/s', '', $contents); + // -{* ... *}- for the iframe templates with custom delimiters. + return (string) preg_replace('/-\{\*.*?\*\}-/s', '', $contents); + } + + /** + * Extract the FIRST `` block inside `` + * (the bootloader) from a template and normalize whitespace so + * cosmetic differences (spaces vs tabs, indentation depth, line + * endings) don't trip the equivalence check. The bootloader is + * always the first inline script — it sits between `` and + * `<link rel="stylesheet">` per the placement contract. + * + * Returns the normalized body (the script's text content with + * leading/trailing whitespace stripped and internal whitespace + * collapsed to single spaces). Returns null when no inline script + * is found, so the caller can name the missing-bootloader case + * separately from the drift case. + */ + private static function extractNormalizedBootloaderBody(string $contents): ?string + { + $contents = self::stripSmartyComments($contents); + // Match the first <script>...</script> in the document. The + // bootloader is the only inline script in <head> across all + // five templates (the iframe templates have additional + // <script src="..."> tags below the stylesheet link, but + // those are SRC scripts and don't match `<script>` without + // attributes). `<script>` with NO attributes is the canonical + // shape — if a future template tweak adds attributes (e.g. + // `<script nonce="...">`) the regex misses it and the test + // fires "bootloader missing" instead of silently passing. + if (!preg_match('#<script>(.*?)</script>#s', $contents, $m)) { + return null; + } + // Collapse all whitespace runs to single spaces, trim ends. + return trim((string) preg_replace('/\s+/', ' ', $m[1])); + } + + public function testEveryTemplateShipsTheBootloader(): void + { + $misses = []; + + foreach (self::templatesRequiringBootloader() as $relativePath) { + $contents = self::stripSmartyComments(self::loadTemplate($relativePath)); + + foreach (self::requiredFragments() as [$fragment, $why]) { + if (!str_contains($contents, $fragment)) { + $misses[] = "$relativePath is missing fragment " . var_export($fragment, true) . " — $why"; + } + } + } + + $this->assertSame( + [], + $misses, + "Anti-FOUC bootloader drift detected (#1438 / #1367). The bootloader's logic must " + . "stay semantically equivalent across `core/header.tpl`, `page_kickit.tpl`, " + . "`page_blockit.tpl`, `page_uploadfile.tpl`, and `updater.tpl` (and semantically " + . "equivalent to `theme.js`'s `applyTheme(currentTheme())` minus the localStorage " + . "write — see AGENTS.md Conventions \"Anti-FOUC theme bootloader\"). The " + . "chromeless surfaces are reachable as TOP-LEVEL navigations from the public " + . "Servers page's right-click context menu (kickit), as `<iframe>`s from the " + . "post-Ban / post-Block success dialogs (kickit / blockit), as popup windows " + . "from admin upload pages (uploadfile), and as standalone upgrade pages " + . "(updater); without the bootloader, dark-mode operators see a stark-white " + . "page or iframe-content blast — the user-reported #1438 symptom. Drift " + . "findings:\n - " + . implode("\n - ", $misses), + ); + } + + /** + * Cross-check: the bootloader sits in `<head>` ABOVE the + * `<link rel="stylesheet" …>` line (parser-blocking + synchronous, + * so the class is set BEFORE the stylesheet resolves the cascade + * for `:root` vs. `html.dark` tokens). A bootloader that lives + * AFTER the stylesheet would still beat `<body>` parsing for a + * static HTML file, but a slow stylesheet network response would + * push the `<script>` execution behind the first paint — re-opening + * the FOUC on slow connections. Pin the order so a future + * "let's move scripts to the bottom" cleanup catches this gate. + */ + public function testBootloaderPrecedesStylesheetLink(): void + { + $misordered = []; + + foreach (self::templatesRequiringBootloader() as $relativePath) { + // Strip Smarty comments BEFORE computing offsets — the + // canonical bootloader in `core/header.tpl` carries a long + // `{*...*}` comment block that explains the placement + // rationale and the prose itself includes the literal + // string `<link rel="stylesheet">` ("Placement: BEFORE + // the <link rel="stylesheet"> below..."). Without the + // strip step, `strpos` returns the comment-block offset + // and the ordering check fires a false positive. + $contents = self::stripSmartyComments(self::loadTemplate($relativePath)); + + $bootloaderOffset = strpos($contents, "localStorage.getItem('sbpp-theme')"); + $stylesheetOffset = strpos($contents, '<link rel="stylesheet"'); + + if ($bootloaderOffset === false || $stylesheetOffset === false) { + // The previous test already names the missing-fragment + // failure; skip here so this test stays scoped to + // ordering alone. + continue; + } + + if ($bootloaderOffset > $stylesheetOffset) { + $misordered[] = "$relativePath has the bootloader AFTER `<link rel=\"stylesheet\">` " + . "(bootloader offset $bootloaderOffset, stylesheet offset $stylesheetOffset)"; + } + } + + $this->assertSame( + [], + $misordered, + "Anti-FOUC bootloader placement regressed. The inline `<script>` must precede " + . "`<link rel=\"stylesheet\">` so a slow stylesheet response doesn't push the " + . "class-flip behind first paint (re-opening the FOUC on slow connections). " + . "See `core/header.tpl`'s in-template comment for the placement rationale.\n - " + . implode("\n - ", $misordered), + ); + } + + /** + * Whitespace-normalized equivalence across every bootloader copy. + * `testEveryTemplateShipsTheBootloader` catches missing fragments, + * but two templates could have entirely different shape around the + * fragments — e.g. one drops the `window.matchMedia &&` null check, + * the other keeps it; both contain `matchMedia('(prefers-color-scheme: dark)').matches` + * so the fragment grep passes. This test extracts the first + * `<script>...</script>` body from each template and asserts they + * all normalize to the SAME string. Catches: + * + * - Surrounding-code drift (added an unrelated statement to one + * copy; the others stayed at the minimal IIFE). + * - Null-check drift (one copy drops `window.matchMedia &&`). + * - try/catch scope drift (one copy narrows the catch to just + * the localStorage call, others keep the wide try around the + * matchMedia branch too). + * - classList method drift (one copy uses `.toggle('dark', d)` + * instead of the correct `.add('dark')`). + * + * The reference is `core/header.tpl` (the chrome's bootloader, + * shipped first, by far the most-touched template — drift will + * always be "the other copies fell behind this one", not vice + * versa). Diff messages name the offending template so a + * maintainer hitting this gate sees which copy drifted. + */ + public function testBootloaderBodiesAreEquivalentAfterNormalization(): void + { + $bodies = []; + foreach (self::templatesRequiringBootloader() as $relativePath) { + $body = self::extractNormalizedBootloaderBody(self::loadTemplate($relativePath)); + if ($body === null) { + // Don't double-fail with the missing-fragments test; + // skip this template for the equivalence check. + continue; + } + $bodies[$relativePath] = $body; + } + + $reference = $bodies['core/header.tpl'] ?? null; + $this->assertNotNull( + $reference, + "core/header.tpl's bootloader is the reference for the equivalence check — " + . "if it's missing, fix `testEveryTemplateShipsTheBootloader` first (the " + . "chrome's bootloader is the canonical shape)." + ); + + $drifted = []; + foreach ($bodies as $path => $body) { + if ($body !== $reference) { + $drifted[] = "$path: bootloader body diverges from core/header.tpl" + . " (normalized — whitespace collapsed). Got:\n " + . $body + . "\n Expected (from core/header.tpl):\n " + . $reference; + } + } + + $this->assertSame( + [], + $drifted, + "Anti-FOUC bootloader body drifted between template copies. The bootloader's " + . "IIFE must be byte-identical (modulo whitespace) across every chromeless " + . "`<head>` surface — diverging shape silently changes behaviour on edge cases " + . "(missing matchMedia, private-mode localStorage SecurityError, etc.) and a " + . "user navigating between sibling pages sees the theme flicker mid-flow. " + . "Findings:\n - " + . implode("\n - ", $drifted), + ); + } + + /** + * Install-wizard exemption: NONE of the templates under + * `web/themes/default/install/` may carry the bootloader. The + * wizard runs against an unconfigured panel with no `theme.js`, + * no toggle, no logged-in user, and therefore no + * `localStorage['sbpp-theme']` to read. Adding the bootloader + * there without a paired toggle would be a no-op at best + * (`localStorage.getItem('sbpp-theme')` returns `null` → + * fall-through to system → resolves to OS-dark only when OS is + * dark) and a confusing one-off "wizard partially-honours-OS" + * surface at worst. The exemption is documented in AGENTS.md + * Conventions "Anti-FOUC theme bootloader" → "The install wizard + * (`web/install/_chrome.tpl`) does NOT carry the bootloader."; + * pin it across EVERY install template so a well-meaning "let's + * mirror the panel chrome" sweep has to delete this test first + * (forcing the conversation). + * + * Pre-review (#1438) this test only checked `_chrome.tpl`. The + * gap let `_chrome_close.tpl` or any of the per-step + * `page_*.tpl` templates silently carry the bootloader; the + * follow-up review caught this. Now globs the whole directory. + */ + public function testInstallWizardTemplatesDoNotCarryBootloader(): void + { + $installDir = ROOT . 'themes/default/install'; + $this->assertDirectoryExists( + $installDir, + 'install/ template directory must exist for the exemption check to land somewhere meaningful', + ); + + $templates = glob($installDir . '/*.tpl'); + $this->assertNotFalse( + $templates, + "glob() returned false for $installDir — filesystem error?", + ); + $this->assertNotEmpty( + $templates, + "No *.tpl files under $installDir — install wizard moved? Update this test or the wizard.", + ); + + $offenders = []; + foreach ($templates as $path) { + $contents = (string) file_get_contents($path); + $stripped = self::stripSmartyComments($contents); + if (str_contains($stripped, "localStorage.getItem('sbpp-theme')")) { + $offenders[] = basename($path); + } + } + + $this->assertSame( + [], + $offenders, + 'Install wizard templates must NOT carry the anti-FOUC bootloader. The wizard ' + . 'runs pre-configuration with no `theme.js` / no theme toggle / no logged-in ' + . 'user, so `localStorage[\'sbpp-theme\']` is never set during install. ' + . 'Adding the bootloader here without a paired theme toggle silently introduces ' + . 'an inconsistent partial-OS-honouring surface. See AGENTS.md Conventions ' + . '"Anti-FOUC theme bootloader" → "The install wizard does NOT carry the ' + . "bootloader.\". Offenders:\n - " + . implode("\n - ", $offenders), + ); + } +} diff --git a/web/themes/default/page_blockit.tpl b/web/themes/default/page_blockit.tpl index 7f1c7a1a5..9532ff978 100644 --- a/web/themes/default/page_blockit.tpl +++ b/web/themes/default/page_blockit.tpl @@ -9,6 +9,21 @@ match the kickit template — see that file for the per-block explanation. The only delta on the wire is `$length` (the block duration in minutes) being forwarded into Actions.BlockitBlockPlayer. + + Anti-FOUC bootloader (#1438): paired with the same change to + `page_kickit.tpl`. Today the blockit iframe is `display:none` in + `page_admin_comms_add.tpl` (it exists purely to fan a `sm_block_*` + rcon out to every server; the operator never sees it), so the + user-visible dark-mode bug only manifests on kickit. The + bootloader is added here for parity and future-proofing: if the + blockit iframe is ever made visible (matching the kickit shape) + OR reached as a top-level navigation (matching the kickit + server-context-menu shape), the dark-mode regression would + silently come back. Keeping both templates in lockstep is what + makes the regression test `IframeChromeAntiFoucBootloaderTest` + a reliable gate. See the matching block in `page_kickit.tpl` for + the full rationale and the byte-equivalence contract with + `core/header.tpl`. *}- <!DOCTYPE html> <html lang="en"> @@ -17,6 +32,16 @@ <meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="csrf-token" content="-{$csrf_token}-"> <title>Block player + diff --git a/web/themes/default/page_kickit.tpl b/web/themes/default/page_kickit.tpl index 88c24d6f4..ba1055e57 100644 --- a/web/themes/default/page_kickit.tpl +++ b/web/themes/default/page_kickit.tpl @@ -19,6 +19,32 @@ preserve the IDs the legacy template used so existing parent-window dialog hooks (set_counter, height adjustment) keep working. + + Anti-FOUC bootloader (#1438): the `pages/admin.kickit.php` URL is + reachable two ways in v2.0 chrome: as an iframe inside the + post-Ban "Ban Added" `sb.message.show` dialog (legacy default-theme + surface — `#dialog-placement` only exists on the default theme), + AND as a top-level navigation from the public Servers page's + right-click context menu's "Kick player" item (`web/scripts/server-context-menu.js` + builds the href directly to `pages/admin.kickit.php?check=…`). The + latter is the user-reported #1438 path: an operator in dark mode + right-clicks a player, picks Kick, and lands on this chromeless + iframe template rendered as a full-page document — and the page + paints stark white because `` never gets the `dark` class. + The inline bootloader below mirrors `core/header.tpl`'s shape + (same THEME_KEY 'sbpp-theme', same default 'system', same dark- + resolution predicate; only ADDS the class, never removes — :root + defaults to light) so the very first paint lands in the operator's + persisted theme regardless of how the page was reached. Logic + must stay byte-equivalent to the canonical bootloader in + `core/header.tpl` (which in turn mirrors `theme.js`'s + `applyTheme(currentTheme())` minus the localStorage write); the + regression gate is `web/tests/integration/IframeChromeAntiFoucBootloaderTest.php`. + See "Anti-FOUC theme bootloader" in AGENTS.md Conventions for the + full contract. Wrapped in IIFE + try/catch because localStorage + throws on private-mode iframes / SecurityError and matchMedia is + missing on very old browsers — defaults to light on either failure, + matching the chrome's shape. *}- @@ -27,6 +53,16 @@ Kick player + diff --git a/web/themes/default/page_uploadfile.tpl b/web/themes/default/page_uploadfile.tpl index 7160920cc..d7e56ba06 100644 --- a/web/themes/default/page_uploadfile.tpl +++ b/web/themes/default/page_uploadfile.tpl @@ -1,28 +1,55 @@ {* - SourceBans++ 2026 — page_uploadfile.tpl - Bound view: \Sbpp\View\UploadFileView (web/includes/View/UploadFileView.php). + SourceBans++ 2026 — page_uploadfile.tpl + Bound view: \Sbpp\View\UploadFileView (web/includes/View/UploadFileView.php). - Self-contained popup window opened via window.open(...) from one - of the parent admin pages (admin.uploadicon.php, - admin.uploadmapimg.php, admin.uploaddemo.php). On a successful - upload the page handler builds a {$message} blob containing - `` - using JSON_HEX_* flags so the admin-controlled filename is safe - to interpolate into both the HTML attribute and the JS string - layers (#1113 fix). The template is the only Phase B/C template - that legitimately needs `{$message nofilter}`. + Self-contained popup window opened via window.open(...) from one + of the parent admin pages (admin.uploadicon.php, + admin.uploadmapimg.php, admin.uploaddemo.php). On a successful + upload the page handler builds a {$message} blob containing + `` + using JSON_HEX_* flags so the admin-controlled filename is safe + to interpolate into both the HTML attribute and the JS string + layers (#1113 fix). The template is the only Phase B/C template + that legitimately needs `{$message nofilter}`. - The theme stylesheet path is hardcoded relative to the popup's - URL (/pages/admin.upload*.php), so `../themes/default/css/theme.css` - resolves correctly. + The theme stylesheet path is hardcoded relative to the popup's + URL (/pages/admin.upload*.php), so `../themes/default/css/theme.css` + resolves correctly. + + Anti-FOUC bootloader (#1438): this template renders a separate + popup window opened with `window.open(...)` from a parent admin + page (which is dark-mode-aware via `core/header.tpl`). The popup + is same-origin, so `localStorage['sbpp-theme']` is reachable + from here — but the popup ships its own chromeless `` and + doesn't load `theme.js`, so without the bootloader below the + popup paints stark white over the operator's dark-mode parent. + The inline script mirrors `core/header.tpl`'s bootloader exactly + (same THEME_KEY 'sbpp-theme', same default 'system', same dark- + resolution predicate). Note the body explicitly uses + `background:var(--bg-page)`, so the resolved tokens drive the + paint directly; without `html.dark` the page would resolve to + the `:root` light tokens regardless of operator preference. See + "Anti-FOUC theme bootloader" in AGENTS.md Conventions for the + full contract; regression gate is + `web/tests/integration/IframeChromeAntiFoucBootloaderTest.php`. *} - - - {$title} : SourceBans++ - + + + {$title} : SourceBans++ + +
diff --git a/web/themes/default/updater.tpl b/web/themes/default/updater.tpl index 1409f9492..50791ef3e 100644 --- a/web/themes/default/updater.tpl +++ b/web/themes/default/updater.tpl @@ -1,28 +1,53 @@ {* - SourceBans++ 2026 — updater.tpl + SourceBans++ 2026 — updater.tpl - Standalone wizard rendered by web/updater/index.php after Updater.php - has finished applying any pending migrations. View: - Sbpp\View\UpdaterView (just `$updates`). + Standalone wizard rendered by web/updater/index.php after Updater.php + has finished applying any pending migrations. View: + Sbpp\View\UpdaterView (just `$updates`). - The updater runs in its own bootstrap context — it does NOT go - through index.php's page-builder, so the chrome from - `core/header.tpl` + `core/footer.tpl` is intentionally not pulled - in. This template is therefore a complete document - and links the theme stylesheet directly. Asset paths are relative - to /web/updater/ (where the script runs from), so `../themes/...` - points at /web/themes/default/. + The updater runs in its own bootstrap context — it does NOT go + through index.php's page-builder, so the chrome from + `core/header.tpl` + `core/footer.tpl` is intentionally not pulled + in. This template is therefore a complete document + and links the theme stylesheet directly. Asset paths are relative + to /web/updater/ (where the script runs from), so `../themes/...` + points at /web/themes/default/. - Test hooks: each card header carries a stable - `data-testid="updater-"` attribute. + Test hooks: each card header carries a stable + `data-testid="updater-"` attribute. + + Anti-FOUC bootloader (#1438): the updater is hit by logged-in + admins (same origin as the panel), and the body uses + `background:var(--bg-page);color:var(--text)` directly — so + without the bootloader, an admin in dark mode upgrading the + panel lands on a stark-white "Updater" page. The inline script + below mirrors `core/header.tpl`'s bootloader exactly; the + byte-equivalence contract is gated by + `web/tests/integration/IframeChromeAntiFoucBootloaderTest.php`. + See "Anti-FOUC theme bootloader" in AGENTS.md Conventions for + the full contract. Distinct from the install wizard's + `_chrome.tpl` (which is intentionally exempt — pre-install + panel has no logged-in user / no theme.js / no persisted + preference); the updater runs against a configured panel and + inherits the operator's already-set theme preference. *} - - - Updater | SourceBans++ - + + + Updater | SourceBans++ + +