LearnNewsExamplesServices
Frontmatter
id17739
titleThe theme reveal starts at half its click coordinates in Chrome
stateClosed
labels
bugairegressioncoreportal
assigneesneo-opus-ada
createdAtAug 24, 2026, 11:59 PM
updatedAtAug 25, 2026, 1:20 AM
githubUrlhttps://github.com/neomjs/neo/issues/17739
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 25, 2026, 1:20 AM

The theme reveal starts at half its click coordinates in Chrome

Closed Backlog/active-chunk-19 bugairegressioncoreportal
neo-opus-ada
neo-opus-ada commented on Aug 24, 2026, 11:59 PM

Context

The theme-switch button plays a circular reveal: a clip-path circle grows from the click point across ::view-transition-new(root). The operator reported that the reveal now starts from the centre of the header instead of from the button, in Chrome but not Safari, on latest dev.

Investigated live against the portal app on Chrome 148, devicePixelRatio: 2, viewport 1062×997. The observed operation is a click on the header theme button; everything below separates what was measured from what is inferred.

Nothing in our code changed. apps/portal/view/ViewportController.mjs has had no commit since f525d56d0a — "Implement Spatial Theme Transition (The Wave)" (#8856), the original implementation — and the radius formula arrived in that same commit. The trigger is a browser behaviour change; the defect is that our code hands Chrome a unit it now resolves differently, and that nothing on our side could have noticed.

The Problem

Chrome resolves the pixel lengths in a clip-path on a view-transition pseudo-element in device pixels, while the pseudo-element's box is in CSS pixels. Every length is therefore divided by devicePixelRatio on screen: the circle's centre and its radius.

Measured, in one click:

quantity value
computed style, sampled 8× across the animation circle(<r>px at 928px 29px) — position stable, radius interpolating cleanly
rendered centre, fitted from three points on the arc in a screen recording ≈ CSS (479, 16)
ratio ≈ 1.94, ≈ 1.9 — i.e. devicePixelRatio, independently confirmed as 2

Half of (928, 29) is (464, 14.5): a button at the top right renders at the horizontal centre, still at the top. That is exactly "the centre of the header" — the original report was a precise observation, and it is the fingerprint of the halving.

Two confirmations, each a single click:

  • multiplying the keyframe values by devicePixelRatio → the circle originates at the button in Chrome;
  • rewriting the keyframes to percentages, with no dpr correction → the circle originates at the button in Chrome.

The second is the important one: percentages resolve against the reference box, so they never enter the length-resolution path where the bug lives.

A second, independent defect sits in the same expression and partially masked the first. The radius is Math.hypot(Math.max(x, 3000 - x), Math.max(y, 3000 - y)) — a hardcoded 3000 described in-line as a "simplified max calculation". For this click it yields 3610px, where the farthest viewport corner is 1350px. The animation therefore covers the viewport at ~55% of its timeline (bracketed by the samples at 242ms = 951px and 300ms = 1478px), so roughly 46% of the 500ms renders entirely off-screen. Halved-radius and oversized-radius were cancelling each other, which is part of why the animation stayed plausible enough to go unreported.

The Architectural Reality

  • src/main/DomAccess.mjs#startViewTransition() runs document.startViewTransition(), then inside transition.ready.then() calls document.documentElement.animate(keyframes, options), reaching the pseudo-element only via options.pseudoElement.
  • That method has no await, no .catch(), discards the returned Animation, and return true unconditionally — before the animation is even registered. A browser that stopped honouring the selector, or a keyframe Chrome refuses to parse, reports success identically. This is why a rendering divergence survived: the engine has no channel through which it could have failed.
  • The keyframes are built in view/ViewportController.mjs in portal and agentos (plus one private downstream app, in its own repository). Correction to an earlier revision of this body: they are not byte-identical. agentos already guards with data.clientX !== undefined && data.clientY !== undefined and has an else branch revealing from 0,0 with radius: 3000; portal has the truthiness guard and no fallback. Same defect, two different spellings — which is itself the argument for the unit decision living in one engine place rather than one copy per app.
  • x/y/radius are declared with no fallback values. When data.clientX is falsy the keyframes become circle(0px at undefinedpx undefinedpx), which fails to parse; the property is dropped and the UA cross-fade runs. A missing coordinate therefore produces a fade, never a misplaced circle — which is how coordinate-loss was eliminated as a cause.
  • if (data.clientX) is a truthiness guard, so a legitimate clientX === 0 (viewport left edge) reads as "no coordinates". Latent, not implicated in this defect, and cheap to correct in the same edit.
  • Nothing in src/, apps/, or resources/scss/ sets view-transition-name or view-transition-class, so there is a single root group. Confirmed by measurement: ::view-transition, ::view-transition-group(root), ::view-transition-image-pair(root) and ::view-transition-new(root) all report exactly 1062px × 997px.

The Fix

Express the reveal in box-relative units, so it never depends on how a browser resolves lengths inside a view-transition pseudo-element:

  • centre: ${x / innerWidth * 100}% / ${y / innerHeight * 100}%
  • radius: 0%150%. For circle() a percentage radius resolves against √(w²+h²)/√2, so 150% exceeds the full diagonal and covers the viewport from any origin — which also retires the hardcoded 3000 rather than re-tuning it.

Apply in all three view/ViewportController.mjs copies. Correct the if (data.clientX) guard to != null in the same edit.

Separately, give src/main/DomAccess.mjs#startViewTransition() a way to fail: await the animation registration, .catch() it, and return a result that distinguishes "the transition ran" from "the browser ignored us".

Deliberately not a × devicePixelRatio correction: it works only because Chrome is wrong, it would break Safari, and it would break again when Chrome fixes the bug. Any UA-conditional variant carries the same rot with an added detection surface.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
Neo.main.DomAccess#startViewTransition(data) src/main/DomAccess.mjs Resolves true as soon as the transition has started — deliberately not awaited, because delay is the caller's window to mutate the DOM before capture. Reveal failure is reported asynchronously via console.warn, never through the return value. No document.startViewTransitionfalse, as today. A rejected ready or a throwing registration warns and leaves the transition intact. A reveal that registers and then rasterises wrongly is outside what this contract can observe. JSDoc states what the boolean certifies, and what the catch does and does not cover. Sequencing is structural rather than test-asserted: awaiting ready would close the capture window, so no arm can assert the awaited form without breaking the feature.
Theme-reveal keyframes in view/ViewportController.mjs (×3 apps) #8856 original implementation Percentage centre and 0%150% radius; no pixel lengths in the clip-path. clientX == null → skip the custom animation and let the UA cross-fade run, as today. Chrome renders the reveal from the click point with no dpr correction; Safari unchanged.

Decision Record impact

none — no ADR governs the view-transition surface, and this changes units and error handling, not architecture.

Acceptance Criteria

  • In Chrome, the theme reveal's circle originates at the click point. Verified against the same measurement used here: the fitted rendered centre matches clientX/clientY within a small tolerance, not merely "it looks right".
  • RESOLVED — Safari renders it correctly, and keeps doing so. The ambiguity in an earlier revision of this body (does "not in Safari" mean renders correctly or ignores pseudoElement and cross-fades?) is settled by operator check: Safari was already correct with pixel units, and is unaffected by the percentage form. Safari does run the custom animation; only the length resolution differed. This makes the fix browser-neutral rather than merely Chrome-verified.
  • No devicePixelRatio term and no browser/UA conditional appears in the fix.
  • The hardcoded 3000 is gone; the reveal covers the viewport from an origin at any corner, at any viewport size.
  • The reveal reaches full coverage at the end of its duration, not at ~55% — the off-screen tail is eliminated, and this is checked by sampling computed clip-path, not by eye.
  • Both in-repo view/ViewportController.mjs copies stop building clip-path strings at all; a grep proves no pixel-unit copy survives in this repository. The private downstream app carries the same defect and is fixed in its own repository — it is not blocked by this ticket, because the engine keeps accepting the old animate payload.
  • A zero coordinate is honoured as an origin. portal used a truthiness guard, so clientX === 0 at the viewport's left edge silently became a cross-fade.
  • AC corrected. An earlier revision demanded that startViewTransition() return a value distinguishing "ran" from "browser ignored it". That is unsatisfiable without breaking the feature: data.delay exists so the caller can mutate the DOM inside the capture window, and transition.ready resolves only after both snapshots are taken — awaiting it before returning would make setTheme() run after the new state was captured, and the transition would capture the unchanged DOM twice. The satisfiable requirement: the failure path must stop being silent. The animation's rejection is caught and warned rather than resolving as success, and the JSDoc states precisely what the return value certifies ("the transition started", not "the reveal rendered").
  • RESOLVED by construction — no post-merge re-check owed. This AC assumed the fix would carry a dpr-dependent term whose behaviour at dpr 1 needed separate confirmation. It does not: the geometry is computed from width/height alone, no devicePixelRatio appears in any shipped code path, and the spec arm asserting no pixel length can be emitted fails if one returns. There is no mechanism by which a dpr:1 display could diverge, so there is nothing a re-check could observe. Left as an unchecked obligation it would advertise a residual the code cannot have.

Out of Scope

  • Filing the upstream Chromium bug. Worth doing and separately owned; this ticket makes us correct regardless of whether Chrome fixes it.
  • Any other view-transition surface (route/card transitions). This ticket is the theme reveal only.
  • De-duplicating the three ViewportController copies into a shared primitive. Real, and a larger design question than this defect should decide.
  • Changing the reveal's duration, easing, or visual design.

Avoided Traps

  • × devicePixelRatio. Confirmed working in Chrome, and still wrong: it encodes the bug, breaks the correct browser, and rots on the fix.
  • Trusting getComputedStyle. It reports the specified value; the defect is downstream in rasterisation. Eight samples across the animation showed a stable, correct at 928px 29px while the screen showed otherwise. No console probe on this path could have found it — a screen recording did.
  • Reading "not in Safari" as "Safari is correct". It may mean Safari runs no custom animation at all. Left as an explicit AC rather than an assumption.
  • Treating the oversized radius as cosmetic. It cancels part of the halving, so fixing either one alone changes the visual result in a way that could read as a new regression.

Related

  • #8856 — the original Spatial Theme Transition, which introduced both the keyframes and the 3000 constant
  • src/main/DomAccess.mjs · apps/portal/view/ViewportController.mjs · apps/agentos/view/ViewportController.mjs
  • CSS View Transitions Level 1/2 — snapshot containing block (measured here as viewport-identical on desktop, so not the cause)

Origin Session ID: 85b245b1-fa02-49fa-96f6-54e36eda9e4e

Retrieval Hint: query_raw_memories("view transition clip-path device pixel ratio halved theme reveal origin Chrome") Retrieval Hint: startViewTransition pseudoElement clipPath circle percentage units dpr halving 3000 radius overshoot

Backlog Credit

backlog-ledger: resolved #17725 #16461 #17728 #17723 => permits #17739

Verified independently rather than relayed: all four closed between 20:28:47Z and 21:15:19Z, after the 4:1 directive at ~`20:23Z and before this ticket was created at ~21:58Z`, with no intervening AI-authored issue consuming them.

Creation Checks

  • Live latest-open sweep: latest 20 open issues read at 2026-08-24T21:56:46Z; no equivalent found. Nearest by title is #17203 (theme-staleness guard docs), a different surface.
  • A2A in-flight claim sweep: last 30 messages, all read-states. Active claims are #16589, #16508, #17370 — none overlaps this scope.
  • Structure-map gate: N/A — the surface is src/main/ and apps/*/view/, not ai/, Agent OS, MCP, Memory Core, orchestration, or skills.
  • Evidence class: L3 — live browser measurement plus a screen recording, on the operator's Chrome 148 at devicePixelRatio: 2.

⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code

tobiu referenced in commit 5c584a3 - "fix(main): the theme reveal uses box-relative units, not pixel lengths (#17739) (#17743) on Aug 25, 2026, 1:20 AM
tobiu closed this issue on Aug 25, 2026, 1:20 AM