From 1c4ea9c99a370906e57a2a2151007292e2bef4f9 Mon Sep 17 00:00:00 2001 From: TomTong Date: Fri, 14 Aug 2026 20:51:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(appearance):=20don't=20let=20the=20boot=20s?= =?UTF-8?q?cript=20fabricate=20an=20explicit=20them=E2=80=A6=20(#6808)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(appearance): don't let the boot script fabricate an explicit theme choice `syncSettings` in static/boot.js is careful to let the server win on a first visit. Its comment says why: // empty (new-browser) state is indistinguishable from a user who chose // the defaults. To avoid blocking server→client sync on first visit we // only let localStorage override the server when it carries an explicit // user-selectable theme value or a NON-DEFAULT skin. The inline bootstrap in static/index.html defeats that. It resolves a theme with `localStorage.getItem('hermes-theme')||'dark'` and then unconditionally writes the result back: localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin); On a brand-new browser that stores `hermes-theme=dark` before any request is made. By the time syncSettings runs, `lsHasExplicitTheme` is true, so: 1. the server's `theme`/`skin` in SETTINGS_DEFAULTS (or settings.json) is ignored, and 2. the reconcile branch below POSTs the fabricated value back to /api/settings, overwriting the stored setting it was supposed to be reading. The net effect is that the server-side appearance default is unreachable: a fresh browser always lands on dark and then persists it server-side. Deployments that set a different default in SETTINGS_DEFAULTS never see it applied. Fix: only rewrite localStorage when there was already appearance state to normalise. A fresh visit now leaves localStorage untouched, so `lsHasExplicitTheme` is false and syncSettings takes the server value exactly as its comment intends. Deliberately keeps the existing normalisation. The write is not pointless — it canonicalises legacy names (`solarized` → `dark` + `poseidon`). Gating on "was there any prior state" rather than per-key preserves that: with `hermes-theme=solarized` and no `hermes-skin`, the derived skin is still written, so the mapping survives the next load. A per-key guard would have silently dropped it. Verified by driving the bootstrap in node against a stubbed localStorage. Behaviour is byte-identical to before for every input that has prior state — legacy `solarized`, an explicit `light`+`mono`, and a skin-only `slate` all produce the same painted result and the same localStorage. The only case that changes is the empty one, which is the bug: before {} → localStorage {hermes-theme: dark, hermes-skin: default} server locked out after {} → localStorage {} server wins Painting is unchanged throughout: a fresh visit still renders dark, since that is still the fallback. * test(appearance): pin absent vs explicit appearance state in the bootstrap Adds the regression coverage asked for in review. Nothing previously distinguished "no appearance stored" from "user chose these values": tests/test_1003_appearance_autosave.py covers picker autosave and tests/test_bugfix_sweep.py covers guarded storage access, but neither fails if the bootstrap starts fabricating a choice again. Executes the REAL bootstrap, lifted out of static/index.html, in node against a stubbed localStorage and documentElement — the same harness used to verify the fix, now committed instead of run by hand. Covers all three states the review called out, plus the pre-paint assertion: - both keys absent -> no setItem at all, AND the dark class is still applied (the fix changes persistence only, never the paint) - either key present -> both values normalised and persisted - legacy names -> solarized/slate/monokai/nord/oled still migrate, and the DERIVED skin is still written That last group is why the guard is on the pair rather than per key: with `hermes-theme=solarized` and no `hermes-skin`, the write of the derived skin is the only thing that persists the mapping, so a per-key guard would silently drop it on the next load. Verified as a regression test, not a tautology: against the pre-fix static/index.html, test_fresh_browser_writes_nothing and test_guard_is_on_the_pair_not_per_key both FAIL, while the other nine still pass — the unfixed bootstrap does still paint dark and does still persist migrations, so only the buggy behaviour differs. One harness note worth keeping: the driver and the extracted script are passed as FILES (`node ", init_script_idx) init_block = INDEX_HTML[init_script_idx:end_idx] Declaring `_hadAppearance` as the first `var` in that chain turned `var themes=` into `,themes=`, so `find()` returned -1, `init_block` came out EMPTY, and three assertions failed against the empty string — including "Default theme must remain 'dark'", which read as though this PR had changed the default. It had not: the default is untouched, the block simply could not be located any more. Splitting the declaration (`;var themes=` instead of `,themes=`) restores the literal. Both are `var` in the same function scope, so behaviour is identical — the regression module still passes unchanged, including the empty-store case (no writes, dark class still applied) and all five legacy migrations. Failed on shards 3 and 4 across 3.11/3.12/3.13 while the same shards passed on master, which is what pinned it to this change rather than flake. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Hermes Agent Co-authored-by: Claude Opus 5 Co-authored-by: nesquena-hermes --- static/index.html | 2 +- ...pearance_bootstrap_no_fabricated_choice.py | 188 ++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_6808_appearance_bootstrap_no_fabricated_choice.py diff --git a/static/index.html b/static/index.html index 8f268382..1f55169a 100644 --- a/static/index.html +++ b/static/index.html @@ -17,7 +17,7 @@ - + diff --git a/tests/test_6808_appearance_bootstrap_no_fabricated_choice.py b/tests/test_6808_appearance_bootstrap_no_fabricated_choice.py new file mode 100644 index 00000000..fd34ef5a --- /dev/null +++ b/tests/test_6808_appearance_bootstrap_no_fabricated_choice.py @@ -0,0 +1,188 @@ +"""Regression checks for #6808: the boot script must not fabricate a theme choice. + +`syncSettings` in static/boot.js deliberately lets the server win on a first +visit, because (its own comment) "empty (new-browser) state is indistinguishable +from a user who chose the defaults". The inline bootstrap in static/index.html +used to defeat that: it resolved a theme with +`localStorage.getItem('hermes-theme')||'dark'` and then wrote the result back +unconditionally, so a brand-new browser stored `hermes-theme=dark` before any +request was made. syncSettings then saw an "explicit" value, ignored the +server's SETTINGS_DEFAULTS, and POSTed the fabricated value back — making the +server-side appearance default unreachable for deployments that change it. + +These cases pin the distinction the fix introduces, which nothing covered +before: ABSENT appearance state is not the same as EXPLICIT appearance state. + + - both keys absent -> no setItem at all (and the dark pre-paint is UNCHANGED) + - either key present -> both values are normalised and persisted + - legacy names -> migration still happens, exactly as before + +The third is the reason the guard is on the PAIR rather than per key. The write +is not pointless: it canonicalises legacy names (`solarized` -> dark+poseidon). +With `hermes-theme=solarized` and no `hermes-skin`, a per-key guard would never +write the derived skin and the mapping would be lost on the next load. +""" +import json +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +INDEX_HTML = ROOT / "static" / "index.html" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif( + NODE is None, + reason="node is required to execute the appearance bootstrap harness", +) + + +def _bootstrap_script() -> str: + """The inline appearance bootstrap, lifted out of index.html.""" + html = INDEX_HTML.read_text(encoding="utf-8") + for m in re.finditer(r"", html, re.S): + body = m.group(1) + if "hermes-theme" in body and "legacy" in body and "skins" in body: + return body + raise AssertionError("appearance bootstrap