fix: theme bridge reads <meta name="theme-color"> first, falls back to pixel sample (#70)#71
Conversation
…o pixel sample (#70) Bug #70: in v1.6.1 multi-tab sessions the AppKit chrome (tab bar, title bar, traffic-light area) flashes bright white for an unpredictable period and stays stuck after closing the offending tab. Cygnus reported this as an accessibility / photosensitivity concern (Discord, May 6 2026) — she explicitly said she 'really can't use tabs'. Root cause: v1.6.1's themeBridgeScript samples the page background by walking document.elementsFromPoint(x, y) at three fixed viewport coordinates. Any opaque modal, lightbox, settings panel, file-tree overlay, or image preview that covers any of the three sample points and stays visible past the 2.5s stability gate makes the bridge fire the *overlay's* colour. AppDelegate then propagates that colour to every browser window, and surviving windows' bridges match-suppress further dark samples (their lastReportedHex is now white) so the chrome stays white indefinitely. The pixel-sampling architecture is fundamentally fragile against legitimate page overlays. The right architectural fix is to read the WebUI's authoritative source of truth instead. Companion PR landed on hermes-webui side (nesquena/hermes-webui#1748) exposing <meta name="theme-color" id="hermes-theme-color"> in the static <head>, with boot.js updating the content attribute on every theme or skin change. The tag is page-controlled (not overlay-derived) and overlay-immune by construction. This PR (Swift / WKUserScript JS only — no Swift code added or removed): 1. New themeColorMetaBackground() helper in the documentStart user script reads the meta tag's content. Validates the value matches the forms parseCSSColor() accepts (#RGB / #RRGGBB / rgb() / rgba()) so a malformed meta value can't poison lastReportedHex and reproduce #70 in a different shape. 2. effectiveBackground() prefers the meta tag when present and falls back to the existing three-point pixel sampling when absent. Self-hosters on older WebUI servers (pre-v0.51.x) get exactly v1.6.1 behaviour — no regression. 3. New MutationObserver entry on the meta tag's content attribute so toggles propagate immediately rather than waiting for the existing 2-second poll tick. 4. The 2.5s stability gate, match-suppression, lastReportedHex, and persisted UserDefaults cache are all unchanged. Opus pre-commit review (1M context, full file inspection): - Verified Swift template-literal escaping: my regex initially used \\( which Swift would parse as a string interpolation. Caught and fixed to \\\\( to match existing line 358's escaping. - Confirmed MutationObserver semantics: one observer instance can observe multiple unrelated nodes with their own attributeFilter. - Confirmed backward compatibility: bytes-identical fallback when meta tag is absent. - Hardening absorbed: regex-validate meta content forms before returning so a future malformed meta value falls through to pixel sampling rather than poisoning the suppression state. Closes #70. Refs nesquena/hermes-webui#1748.
Pre-merge review — agent + Opus 4.7 advisorJS-only fix inside the existing So the bridge has a real authoritative source to read. Opus 4.7 advisor — verdict: ship itRan Regex escape levels — VERIFIED CORRECTThe actual file has
Inside Swift Regex correctness — VERIFIED CORRECTPattern at runtime:
MutationObserver semantics — VERIFIED CORRECTPer WHATWG DOM spec, calling Also verified: existing observations on documentElement/body don't pass documentStart vs inline-meta-write ordering — VERIFIED NO RACESequence:
The first synchronous Issue #70 resolution — RESOLVED IN PRACTICEThe bug shape was: overlay poisons one tab's pixel sample → This PR removes the poisoning step (overlays can't touch the meta tag). The PR description acknowledges that the global-fanout step is structurally unchanged. Verified residuals — all currently theoretical given the architecture:
The structural fix (per-window appearance + per-window background, no global fanout) is appropriately deferred and worth a tracking issue (not a blocker for #70). MUST-FIXNone. SHOULD-FIXNone. Non-blocking observations (deferred, not gating this PR)
CI
Mac build verificationPinging @nesquena for If the build is clean: I'll set the CHANGELOG date, push, tag v1.6.2, CI builds the DMG, post-release verification, close #70. |
nesquena
left a comment
There was a problem hiding this comment.
Verified on Mac agent at tip 32e0432.
Build/test results
swift build -c release— Build complete (10.9s, 0 warnings)swift test— 22/22 passedbash build.sh—.appbuilt, ad-hoc signed, installed to/Applications
Code review findings
Architecture — right call to abandon pixel-sampling as the primary signal. The three-point elementsFromPoint walk was always going to lose to any opaque overlay that lingered past the 2.5 s gate, and once it lost it would stay lost because the chrome's own colour had become lastReportedHex and match-suppression would drop every subsequent dark sample. The page-controlled meta tag is the only source of truth that's guaranteed not to be obscured by page-content.
Defensive validation — the regex ^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$|^rgba?\( is the right shape: tight on the #hex alternative (anchored at both ends), loose on the rgba?( alternative (start-anchor only) because Swift's parseCSSColor() is the actual parser downstream. Importantly it rejects bare colour names ("white", "transparent"), unresolved CSS custom properties (var(--bg)), and hsl() — anything that would make parseCSSColor() return nil and propagate poison into lastReportedHex. Falling through to pixel-sampling on a malformed meta value is exactly the right fallback.
MutationObserver re-use — one MutationObserver instance can observe multiple unrelated nodes with their own attributeFilter, so the third observer.observe(themeMeta, …) call inside start() doesn't conflict with the existing documentElement / body observers. Confirmed by spec.
Backward compatibility — when document.getElementById('hermes-theme-color') is null (older WebUI server, error route, raw page), themeColorMetaBackground() returns null and effectiveBackground() falls through to the unchanged pixel-sampling path. Behavior on un-upgraded servers is bytes-identical to v1.6.1.
Swift template-literal escaping — the regex source uses \\( (4 backslashes in Swift → 2 in the JS string literal → \( in the regex itself, matching a literal (). Matches the existing escape style on line 358.
Preserved invariants — STABILITY_MS = 2500, cachedHex match-suppression, the persisted UserDefaults theme cache, and the documentStart background-paint script are all untouched. Cmd+R / Cmd+T no-flicker behavior is preserved by construction.
Info.plist keys
NSAppTransportSecurity→{ NSAllowsArbitraryLoads = false; NSAllowsArbitraryLoadsInWebContent = true }✓SUPublicEDKey→daAlTqdBbYPSCDjS9IfTCJOFDo1jqtjMRZluhtAbKMY=✓SUFeedURL→https://hermes-webui.github.io/hermes-swift-mac/appcast.xml✓CFBundleShortVersionString→1.6.1(CHANGELOG entry is[v1.6.2] — TBD; version bump happens at tag time)
Non-blocking follow-ups
- The meta tag dependency on hermes-webui v0.51.x+ — confirm the companion PR (
nesquena/hermes-webui#1748) is merged and tagged before tagging v1.6.2 here, otherwise self-hosters who upgrade the Mac app first will silently fall back to pixel sampling and #70 will reproduce until they upgrade the WebUI side. - If the meta-tag path becomes the steady state and pixel-sampling ends up only running on old-server fallback, consider widening the meta-tag regex to also accept
hsl()/hsla()/ 4- and 8-digit hex (#RGBA/#RRGGBBAA) onceparseCSSColor()learns those forms — currently those would silently fall through to pixel-sampling on otherwise-current WebUI installs that pick non-RGB colour formats.
Ready to merge and tag v1.6.2.
fix: theme bridge reads
<meta name="theme-color">first, falls back to pixel sample (#70)What this PR is
A surgical fix for the bug Cygnus filed in #70 — multi-tab sessions in v1.6.1 have a tab-bar / title-bar strip that flashes bright white for an unpredictable period, and stays stuck after closing the offending tab. She explicitly flagged this as an accessibility / photosensitivity concern: "I may not be too useful testing this particular feature one because it genuinely gives me a headache".
JS-only changes inside the existing
themeBridgeScriptWKUserScripttemplate literal. No Swift code added or removed.Root cause
v1.6.1's bridge samples the WebContent's background by walking
document.elementsFromPoint(x, y)at three fixed viewport coordinates and reporting the first opaque background it finds. The whole architecture is fragile against legitimate page overlays:AppDelegate.updateAppearancepropagates that colour to every browser window. With multi-tab, one tab's overlay-derived colour poisons the chrome of every other tab.lastReportedHexis now white), so the chrome stays white indefinitely — exactly Cygnus's "stays that way after I close one of the tabs, I gotta wait for my long thing to finish running before I can refresh".The fix
A companion PR landed on the WebUI side (nesquena/hermes-webui#1748) exposing the active theme as
<meta name="theme-color" id="hermes-theme-color">in the static<head>, updated byboot.json every theme/skin change. The tag is page-controlled, not overlay-derived, and overlay-immune by construction.This PR makes the bridge prefer that authoritative source:
Three deliberate properties:
effectiveBackground()returns the meta value first; the pixel-sampling block is now the fallback.effectiveBackgroundfalls through to the unchanged three-point sampling. Behaviour is byte-identical to v1.6.1 for those users.var(--bg)from a future WebUI bug, an unknown CSS colour name) poisoninglastReportedHexand reproducing bug: tab-bar strip flashes white in multi-tab sessions — theme bridge propagates one tab's overlay sample to all windows (v1.6.1 regression) #70 in a different shape. Anything that doesn't match the formsparseCSSColor()accepts falls through to pixel sampling instead of corrupting the suppression state.The existing
MutationObserveris also extended to watch the meta tag'scontentattribute so toggles propagate without waiting for the 2-second poll tick.What's NOT changed
lastReportedHexis unchanged.loadCachedTheme(),persistCurrentTheme()) is unchanged.AppDelegate.updateAppearanceglobal-fanout is unchanged. With the meta tag as authoritative source, all windows pointing at the same WebUI now report the same value, so the fanout becomes correct-by-construction rather than buggy-and-papered-over. (Per-window scoping is a future enhancement worth considering only if multi-server-per-window becomes a real use case.)Sources/HermesAgent/BrowserWindowController.swiftSwift code is unchanged. Diff is JS-only inside the template literal.Opus pre-commit review (1M context, full file inspection)
Caught one real bug before push:
\(which Swift's"""strings parse as a string interpolation marker. Fixed to\\(to match existing line 358's escaping. Without this catch, the file would have failed to compile on the Mac. Confirmed both regex sites in the file (line 358 and line 397) now use the samergba?\\(form.Hardening absorbed:
themeColorMetaBackground()(the third deliberate property above). Opus's exact recommendation, accepted as written.Verified clean by Opus:
attributeFilter. ✓Verification
The companion WebUI PR (#1748) was browser-verified end-to-end on port 8789 across light/dark themes and all skin variants (Default, Sienna, Sisyphus). Each skin's distinct
--bgreaches the meta tag with exact hex match, no flicker.Mac agent build verification request
@nesquena — would you mind running
swift build -c release && swift test && bash build.shon the Mac agent against this branch? The change is JS-only inside a Swift"""string, but I'd like to confirm nothing in the Swift compile pipeline trips on the regex character class or the template-literal contents before tagging v1.6.2. Branch:fix/theme-bridge-meta-tag-and-per-window.Closes #70.
Refs nesquena/hermes-webui#1748.