Skip to content

fix(1438): paint chromeless <head> surfaces in dark mode on first paint - #1450

Merged
rumblefrog merged 1 commit into
mainfrom
fix/issue-1438-iframe-dark-mode-bootloader
May 25, 2026
Merged

rumblefrog merged 1 commit into
mainfrom
fix/issue-1438-iframe-dark-mode-bootloader

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Summary

  • 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. Those templates don't pull theme.js, so without the inline <script> in <head> there is no theme-resolution path at all and the page paints in light against the :root token defaults.
  • Mirrors the chrome's [Bug] White flash and content flicker when navigating between pages in dark mode #1367 inline bootloader pattern across every chromeless <head> surface in the panel: page_kickit.tpl, page_blockit.tpl (parity), page_uploadfile.tpl (popup over a dark-mode parent admin), and updater.tpl (post-upgrade landing page during a dark-mode admin's flow).
  • The bootloader's resolution rule is semantically equivalent to theme.js's applyTheme(currentTheme()) minus the localStorage.setItem write (theme.js still owns persistence). It's intentionally more defensive than theme.js (window.matchMedia && null check + a broader try/catch covering both localStorage SecurityError and missing matchMedia) so it cannot throw on the earliest realm setup phase where a syntax error would silently leave the page in light mode.

Adversarial-review findings addressed

An adversarial review found four chromeless surfaces (not just two):

  • C1 (critical): original fix missed page_uploadfile.tpl (upload popup) and updater.tpl (post-upgrade landing). Both have the same stark-white-over-dark-parent failure shape. Fixed — added the bootloader to both.
  • I1 (important): the static gate only checked fragment presence, not byte-equivalence. Fixed — added testBootloaderBodiesAreEquivalentAfterNormalization() that extracts the IIFE body from each copy and asserts byte-for-byte equality after whitespace normalization.
  • I3 (important): install-wizard exemption test only checked _chrome.tpl. Fixed — testInstallWizardTemplatesDoNotCarryBootloader() now globs every *.tpl under web/themes/default/install/.
  • I4 (important): docs claimed "byte-equivalence" with theme.js. The bootloader is intentionally more defensive. Fixed — AGENTS.md + the integration test docblock now distinguish semantic equivalence with theme.js from byte equivalence across copies.
  • N8 (nice-to-have): the integration test suppressed file_get_contents errors. Fixed — removed the @.

Regression guards

  • web/tests/integration/IframeChromeAntiFoucBootloaderTest.php (static-grep): asserts every required bootloader fragment ships in all five copies (core/header.tpl, page_kickit.tpl, page_blockit.tpl, page_uploadfile.tpl, updater.tpl), the bootloader precedes <link rel=\"stylesheet\"> in each, the bootloader bodies are byte-equivalent across the five copies after whitespace normalization (catches drift edits that update one copy and forget the others), and NO *.tpl under web/themes/default/install/ carries the bootloader (the install wizard runs against an unconfigured panel with no localStorage to read — exemption documented in AGENTS.md). 4 tests, 8 assertions.
  • web/tests/e2e/specs/flows/iframe-anti-fouc.spec.ts (runtime): six chromium tests cover kickit dark + light + system-OS-dark + system-OS-light + blockit dark + uploadfile dark. The system-OS-light arm specifically guards against the regression mode where the bootloader unconditionally adds html.dark regardless of matchMedia(...).matches (the inverse of system-OS-dark — both arms together pin the full predicate). 6 passed, 6 mobile-chromium skipped (no admin storage state).

The updater surface is NOT covered by the E2E spec because the dev stack auto-seeds the DB out of band via docker/db-init/, so web/updater/data/<N>.php migrations are never applied via the runner in dev and hitting /updater/ raises "Column already exists" on migration 801. The static-grep integration test is the sufficient gate for that surface (the bootloader mechanism is identical across all five copies, enforced by the byte-equivalence test).

Docs

AGENTS.md updated in lockstep:

  • "Anti-FOUC theme bootloader" Conventions block now describes the five-surface contract + the semantic-vs-byte equivalence distinction.
  • "Apply the persisted theme to <html> BEFORE first paint" row in "Where to find what" rewritten to cover all five copies, both regression guards, and the updater-spec-exemption rationale.
  • Two paired Anti-patterns entries updated: "Removing the inline anti-FOUC bootloader …" and "Letting the inline bootloader's resolution logic drift …" both now cover all five surfaces and the three intentional defensive deltas vs theme.js.

Test plan

  • ./sbpp.sh test tests/integration/IframeChromeAntiFoucBootloaderTest.php — 4 passed
  • ./sbpp.sh e2e specs/flows/iframe-anti-fouc.spec.ts — 6 passed, 6 skipped
  • ./sbpp.sh e2e specs/flows/theme-fouc.spec.ts — 6 passed (no regression in chrome bootloader)
  • ./sbpp.sh phpstan — 241 files, no errors
  • ./sbpp.sh ts-check — no errors

Fixes #1438.

…aint

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 `<script>` in `<head>` there is no theme-resolution path at all
and the page paints in light against the `:root` token defaults.

Mirrors the chrome's #1367 inline bootloader pattern across every
chromeless `<head>` surface in the panel:

  - `page_kickit.tpl` (the #1438 reporter's path; iframe AND top-level)
  - `page_blockit.tpl` (parity with kickit — same shape)
  - `page_uploadfile.tpl` (upload popup over a dark-mode parent)
  - `updater.tpl` (post-upgrade landing during a dark-mode admin's flow)

The bootloader's resolution rule is semantically equivalent to
`theme.js`'s `applyTheme(currentTheme())` minus the `localStorage.setItem`
write (theme.js still owns persistence). It's intentionally more
defensive than theme.js (`window.matchMedia &&` null check + a broader
`try/catch` covering both `localStorage` SecurityError and missing
`matchMedia`) so it cannot throw on the earliest realm setup phase
where a syntax error would silently leave the page in light mode.

Regression guards (two halves):

  - `web/tests/integration/IframeChromeAntiFoucBootloaderTest.php`
    (static-grep): asserts every required bootloader fragment ships
    in all five copies, the bootloader precedes `<link rel="stylesheet">`
    in each, the bootloader bodies are byte-equivalent across the five
    copies after whitespace normalization (catches drift edits that
    update one copy and forget the others), and NO `*.tpl` under
    `web/themes/default/install/` carries the bootloader (the install
    wizard runs against an unconfigured panel with no `localStorage`
    to read — exemption documented in AGENTS.md).
  - `web/tests/e2e/specs/flows/iframe-anti-fouc.spec.ts` (runtime):
    six chromium tests cover kickit dark + light + system-OS-dark +
    system-OS-light + blockit dark + uploadfile dark. The system-OS-light
    arm specifically guards against the regression mode where the
    bootloader unconditionally adds `html.dark` regardless of
    `matchMedia(...).matches`.

The updater surface is NOT covered by the E2E spec because the dev
stack auto-seeds the DB out of band via `docker/db-init/`, so
`web/updater/data/<N>.php` migrations are never applied via the runner
in dev and hitting `/updater/` raises "Column already exists" on
migration 801. The static-grep integration test is the sufficient gate
for that surface (the bootloader mechanism is identical across all
five copies, enforced by the byte-equivalence test).

`AGENTS.md` updated in lockstep (the "Anti-FOUC theme bootloader"
Conventions block, the "Apply the persisted theme" row in
"Where to find what", and the two paired Anti-patterns entries) to
document the five-surface contract and the semantic-vs-byte
equivalence distinction.

Fixes #1438.
@rumblefrog
rumblefrog added this pull request to the merge queue May 25, 2026
Merged via the queue into main with commit 4526aa0 May 25, 2026
5 checks passed
@rumblefrog
rumblefrog deleted the fix/issue-1438-iframe-dark-mode-bootloader branch May 25, 2026 18:27
@github-actions github-actions Bot locked and limited conversation to collaborators May 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kicking player when in Dark mode spawns a light mode page

1 participant