Skip to content

fix: theme bridge reads <meta name="theme-color"> first, falls back to pixel sample (#70)#71

Merged
nesquena-hermes merged 2 commits intomainfrom
fix/theme-bridge-meta-tag-and-per-window
May 6, 2026
Merged

fix: theme bridge reads <meta name="theme-color"> first, falls back to pixel sample (#70)#71
nesquena-hermes merged 2 commits intomainfrom
fix/theme-bridge-meta-tag-and-per-window

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

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 themeBridgeScript WKUserScript template 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:

  • Any 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.updateAppearance propagates that colour to every browser window. With multi-tab, one tab's overlay-derived colour poisons the chrome of every other tab.
  • Surviving windows' bridges then match-suppress further dark samples (lastReportedHex is 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 by boot.js on 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:

function themeColorMetaBackground() {
    const meta = document.getElementById('hermes-theme-color');
    if (!meta) return null;
    const content = (meta.getAttribute('content') || '').trim();
    if (!content) return null;
    // Defensive: only trust values that match the forms parseCSSColor()
    // accepts (#RGB / #RRGGBB / rgb() / rgba()).
    if (!/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$|^rgba?\(/.test(content)) return null;
    return content;
}

function effectiveBackground() {
    const meta = themeColorMetaBackground();
    if (meta) return meta;
    // ... existing three-point pixel sampling fallback ...
}

Three deliberate properties:

  1. Meta tag preferred when present. effectiveBackground() returns the meta value first; the pixel-sampling block is now the fallback.
  2. Backward compatible. When the meta tag is absent (older WebUI servers, raw error pages, anyone running pre-v0.51.x on the server side), the function returns null and effectiveBackground falls through to the unchanged three-point sampling. Behaviour is byte-identical to v1.6.1 for those users.
  3. Regex-validated content. Defensive shield against a future malformed meta value (e.g. an unresolved var(--bg) from a future WebUI bug, an unknown CSS colour name) poisoning lastReportedHex and 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 forms parseCSSColor() accepts falls through to pixel sampling instead of corrupting the suppression state.

The existing MutationObserver is also extended to watch the meta tag's content attribute so toggles propagate without waiting for the 2-second poll tick.

const themeMeta = document.getElementById('hermes-theme-color');
if (themeMeta) {
    observer.observe(themeMeta, {
        attributes: true,
        attributeFilter: ['content']
    });
}

What's NOT changed

  • The 2.5-second stability gate is unchanged.
  • Match-suppression on lastReportedHex is unchanged.
  • The persisted UserDefaults theme cache (loadCachedTheme(), persistCurrentTheme()) is unchanged.
  • AppDelegate.updateAppearance global-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.swift Swift 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:

  • Swift template-literal escaping. My initial regex used \( 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 same rgba?\\( form.

Hardening absorbed:

  • The regex content-validation in themeColorMetaBackground() (the third deliberate property above). Opus's exact recommendation, accepted as written.

Verified clean by Opus:

  • MutationObserver semantics — one observer instance can observe multiple unrelated nodes (documentElement, body, themeMeta) each with their own per-call attributeFilter. ✓
  • Backward compatibility — bytes-identical pixel-sampling fallback when meta tag is absent. ✓
  • No memory / retain concerns (JS-only changes). ✓

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 --bg reaches the meta tag with exact hex match, no flicker.

light + Default → #FEFCF7
light + Sienna  → #FAF9F5
dark  + Sienna  → #1F1E1C

Mac agent build verification request

@nesquena — would you mind running swift build -c release && swift test && bash build.sh on 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.

…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.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Pre-merge review — agent + Opus 4.7 advisor

JS-only fix inside the existing themeBridgeScript template literal (no Swift code touched). Companion WebUI side (#1748) is already merged into hermes-webui v0.51.11 (released today, 2026-05-06) — the <meta name="theme-color" id="hermes-theme-color"> tag is live in static/index.html, seeded pre-paint via inline script from localStorage['hermes-theme'], and updated by boot.js _updateThemeColorMeta() on every theme/skin change. Confirmed by reading the merged static/index.html and static/boot.js directly:

static/index.html:23: <meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
static/index.html:24: <script>(function(){try{var t=localStorage.getItem('hermes-theme')...
static/boot.js:1098:  // Sync <meta name="theme-color"> with the active theme's computed --bg.
static/boot.js:1111:    const meta=document.getElementById('hermes-theme-color');

So the bridge has a real authoritative source to read.

Opus 4.7 advisor — verdict: ship it

Ran bash ~/WebUI/scripts/opus-advise.sh with a brief covering all four risk axes. Opus inspected the PR-branch source via gh (not just my brief) and verified each axis:

Regex escape levels — VERIFIED CORRECT

The actual file has \\( (two source backslashes), matching existing line 358:

  • Line 358 (existing, unchanged): s.match(/^rgba?\\((\\d+)\\D+(\\d+)\\D+(\\d+)/)
  • Line 397 (new): /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$|^rgba?\\(/.test(content)

Inside Swift """…""", \\ is one literal backslash and \( is interpolation. So \\( → runtime \( → JS regex literal-paren. Both regexes parse byte-identically at JS runtime.

Regex correctness — VERIFIED CORRECT

Pattern at runtime: /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$|^rgba?\(/. | has lowest precedence, so it's (^#…$) | (^rgba?\().

  • #FEFCF7, #0D0D1A, #FFF → match A ✓
  • rgb(...), rgba(...) → match B ✓
  • var(--bg), transparent, hsl(...), currentColor, named colours → reject ✓
  • #FFFF, #1234567, 8-digit RGBA hex → reject (matches what parseCSSColor accepts) ✓
  • Hex case-insensitive (regex [a-fA-F], Swift UInt8(_:radix:) is case-insensitive) ✓

MutationObserver semantics — VERIFIED CORRECT

Per WHATWG DOM spec, calling .observe() multiple times on the same observer with different target nodes registers independent per-target observations; each retains its own attributeFilter. There's no union across targets — only same-target re-observation replaces options.

Also verified: existing observations on documentElement/body don't pass subtree: true, so attribute changes on a <meta> in <head> would NOT be caught by them — the explicit themeMeta observation is required, not redundant.

documentStart vs inline-meta-write ordering — VERIFIED NO RACE

Sequence:

  1. WKWebView created with prePaintColor = AppDelegate's currentBackgroundColor (cached or dark fallback).
  2. documentStart user script runs → defines functions + observer instance, defers start() because document.readyState === 'loading'.
  3. HTML parser processes <head>. Static meta tag created with content="#0D0D1A". Then the inline <script> runs synchronously, rewriting content from localStorage.
  4. DOMContentLoaded fires → start() runs → first report() reads meta content (post-inline-script value).

The first synchronous report() always sees the post-inline value because DOMContentLoaded follows the inline-script execution.

Issue #70 resolution — RESOLVED IN PRACTICE

The bug shape was: overlay poisons one tab's pixel sample → AppDelegate.updateAppearance fans white out to every window's chrome → match-suppression keeps it stuck.

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-FIX

None.

SHOULD-FIX

None.

Non-blocking observations (deferred, not gating this PR)

  1. ^rgba?\( is loose — passes any string starting with rgb(/rgba(. Swift parseCSSColor cope (returns nil on non-numeric content) and the receiver early-returns on nil. Defense-in-depth holds. Not worth tightening.
  2. Global fanout is unchangedAppDelegate.updateAppearance still mutates every browser window. The meta-tag fix makes this correct in today's single-server world, but the architecture is still sensitive to future features (multi-server-per-window, non-WebUI WKWebViews). Worth a tracking issue.
  3. Inline-script failure mode in static/index.html — try/catch swallows errors; static default is #0D0D1A (dark). For light-theme users on a launch where localStorage read fails, chrome would briefly flash dark before boot.js corrects the meta tag. Out of scope for this PR — worth a low-priority hermes-webui hardening.

CI

Test workflow run 25449348267 — completed SUCCESS 17:00:14 UTC.

Mac build verification

Pinging @nesquena for swift build -c release && swift test && bash build.sh on the Mac agent against the fix/theme-bridge-meta-tag-and-per-window branch. The change is JS-only inside a Swift """ string but I want a clean compile + test + ad-hoc-signed app run before tagging v1.6.2.

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.

Copy link
Copy Markdown
Contributor

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on Mac agent at tip 32e0432.

Build/test results

  • swift build -c release — Build complete (10.9s, 0 warnings)
  • swift test — 22/22 passed
  • bash build.sh.app built, 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 invariantsSTABILITY_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 }
  • SUPublicEDKeydaAlTqdBbYPSCDjS9IfTCJOFDo1jqtjMRZluhtAbKMY=
  • SUFeedURLhttps://hermes-webui.github.io/hermes-swift-mac/appcast.xml
  • CFBundleShortVersionString1.6.1 (CHANGELOG entry is [v1.6.2] — TBD; version bump happens at tag time)

Non-blocking follow-ups

  1. 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.
  2. 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) once parseCSSColor() 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.

@nesquena-hermes nesquena-hermes merged commit 57c48d4 into main May 6, 2026
1 check passed
@nesquena-hermes nesquena-hermes deleted the fix/theme-bridge-meta-tag-and-per-window branch May 6, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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)

2 participants