gui: spinners turn round their own centre: the drawing turns inside the svg, round the viewBox's centre, since WebKit turned a whole svg on a pixel-snapped layer and its ring circled a point ~1.3px off (Agents' "Updating…", Sync, Save, the quota and usage refreshes, the agent fix) (yetone)

Measured on the real screen (headed WebKit + screencapture, 30 frames,
circle fit): the ring's centre wandered 2.7 device px before, 0.2 after.
Playwright screenshots repaint and never showed it. The CSS ring
spinners (sign-in, plugins) measured 0.2 already and are unchanged.
#sync's stop-on-a-whole-turn reads the svg's subtree animations.
Run: cli-update (fails on the old CSS), agent-update-all, sync-spin,
provider-save-busy, agent-app-version, agent-unreachable, aside-native,
usage-card-refresh, usage-remote-cards, usage-stale-answer: all pass,
Chromium + WebKit.
This commit is contained in:
yetone committed 2026-10-09 01:46:48 +08:00
1 parent 05b5965872
commit 97f825d560
5 files changed
+22 -14

No files matched your search

+12 -8
View File
@@ -227,7 +227,10 @@ body.panel .seg { display: none; }
/* the icon turns on a layer of its own — painted in place, WebKit leaves
ghosts of each frame behind (#16) — and always finishes its turn */
.icon svg { transform-origin: 50% 50%; backface-visibility: hidden; }
.icon.spin svg { animation: spin .9s linear infinite; will-change: transform; }
/* a spinner turns its drawing inside the svg, round the viewBox's centre:
WebKit turns a whole svg on a layer snapped to the pixel grid, so off a
whole pixel its ring circles about a point a pixel or so off its centre */
.icon.spin svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .9s linear infinite; }
/* a newer magpie is ready: a quiet pill before the header's icons */
.update {
align-self: center; display: inline-flex; align-items: center; gap: 4px;
@@ -395,7 +398,7 @@ body.panel #agents > .row .ag-handle::before { left: -10px; }
.ag-fix:active { transform: scale(.96); }
.ag-fix:focus-visible { outline: 1.5px solid var(--drift); outline-offset: 1px; }
.ag-fix.busy { pointer-events: none; opacity: .7; }
.ag-fix.busy svg { animation: spin .8s linear infinite; }
.ag-fix.busy svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
/* what has to listen at an address no agent reaches magpie by (#1013): long addresses wrap, and it can be copied */
.drift-why { overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
/* the agent's CLI on its name's line (#202): its version faint, and a quiet
@@ -425,8 +428,8 @@ body.window .row.agent .who .ag-cli { flex: 0 1 auto; overflow: hidden; }
.ag-up:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 1px; }
.ag-up svg { color: var(--muted); }
.ag-up.busy { pointer-events: none; color: var(--muted); }
.ag-up.busy svg { animation: spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce) { .ag-up.busy svg { animation: none; } }
.ag-up.busy svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce) { .ag-up.busy svg > * { animation: none; } }
/* Update all (#727): a line above the list once two or more agents' CLIs
are behind, its pill like a row's */
.ag-updates { flex: none; display: flex; align-items: center; gap: 10px; margin: 0 2px 10px; min-height: 22px; font-size: 12.5px; color: var(--fg-2); }
@@ -718,7 +721,7 @@ body.window .field.solo { grid-column: auto; }
.quota-refresh:focus-visible { width: 14px; margin: 0 3px 0 -2px; opacity: 1; }
.quota-refresh:hover { color: var(--fg); background: var(--pill-hover); }
.quota-refresh.busy { cursor: default; }
.quota-refresh.busy svg { animation: spin .8s linear infinite; }
.quota-refresh.busy svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
@media (hover: none) { .quota-refresh { width: 14px; margin: 0 3px 0 -2px; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .quota-refresh { transition: none; } }
.skeleton { display: block; border-radius: 5px; background: linear-gradient(90deg, var(--skel, var(--pill)) 25%, var(--skel-hi, var(--pill-hover)) 45%, var(--skel, var(--pill)) 65%); background-size: 240% 100%; animation: skeleton-shift 1.4s ease-in-out infinite; }
@@ -2405,8 +2408,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.editor .editor-error.warn { color: var(--amber); }
.busy { opacity: .55; pointer-events: none; }
.saving { opacity: 1; display: inline-flex; align-items: center; gap: 5px; }
.saving svg { flex: none; animation: spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce) { .saving svg { animation: none; } }
.saving svg { flex: none; }
.saving svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce) { .saving svg > * { animation: none; } }
/* agent chips: who is on this provider, click one to pick it a model */
.achips { display: flex; flex-wrap: wrap; gap: 5px; }
@@ -3036,7 +3040,7 @@ body.panel .modal { padding: 16px 12px; }
/* how often the Usage page reads its numbers again, and a way to read them now */
.usage-refresh { display: inline-flex; align-items: center; gap: 2px; margin-right: 10px; }
.usage-refresh .icon { width: 24px; height: 24px; }
.usage-refresh .icon.busy svg { animation: spin .8s linear infinite; }
.usage-refresh .icon.busy svg > * { transform-box: view-box; transform-origin: 50% 50%; animation: spin .8s linear infinite; }
.usage-refresh .sess-pick { height: 24px; }
.led-rank .rk .rk-b { overflow-wrap: anywhere; }
+5 -4
View File
@@ -20395,11 +20395,12 @@ $("#sync").onclick = async () => {
// clock: waiting for animationiteration and then dropping the class
// jumped the icon back by however far it had turned while the page was
// busy drawing the reply — the twitch at the end of every refresh.
const spin = b.querySelector("svg").getAnimations()[0];
// The svg's drawing turns, not the svg (app.css), one animation a part.
const spins = b.querySelector("svg").getAnimations({ subtree: true });
const stop = () => b.classList.remove("spin");
if (spin?.effect?.updateTiming) {
spin.effect.updateTiming({ iterations: (spin.effect.getComputedTiming().currentIteration || 0) + 1 });
spin.finished.then(stop, stop);
if (spins.length && spins.every((a) => a.effect?.updateTiming)) {
for (const a of spins) a.effect.updateTiming({ iterations: (a.effect.getComputedTiming().currentIteration || 0) + 1 });
Promise.all(spins.map((a) => a.finished)).then(stop, stop);
} else stop();
}
};
+3
View File
@@ -130,6 +130,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.waitForTimeout(150);
assert.equal(await page.locator(`${row("codex")} .ag-up`).textContent(), "Updating…");
assert.equal(await page.locator(`${row("codex")} .ag-up.busy`).count(), 1);
// its ring turns inside the svg, round the viewBox's centre: a turned
// svg circles about a point off its centre in WebKit (owner, 10-09)
assert.deepEqual(await page.locator(`${row("codex")} .ag-up svg`).evaluate((s) => [getComputedStyle(s).animationName, getComputedStyle(s.firstElementChild).animationName, getComputedStyle(s.firstElementChild).transformBox]), ["none", "spin", "view-box"]);
// clicked again while it runs: nothing more is asked
await page.locator(`${row("codex")} .ag-up`).click({ force: true });
await page.waitForTimeout(900);
@@ -78,7 +78,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal(await busy.getAttribute("aria-busy"), "true");
const look = await busy.evaluate((b) => {
const s = getComputedStyle(b), svg = b.querySelector("svg"), r = b.getBoundingClientRect(), label = b.querySelector("span").getBoundingClientRect();
return { opacity: s.opacity, events: s.pointerEvents, spin: getComputedStyle(svg).animationName, svgW: svg.getBoundingClientRect().width, inside: label.right <= r.right + 0.5 && label.width > 0, clipped: b.scrollWidth > b.clientWidth + 1 };
return { opacity: s.opacity, events: s.pointerEvents, spin: getComputedStyle(svg.firstElementChild).animationName, svgW: svg.getBoundingClientRect().width, inside: label.right <= r.right + 0.5 && label.width > 0, clipped: b.scrollWidth > b.clientWidth + 1 };
});
assert.equal(look.opacity, "1", "readable, not faded as if off");
assert.equal(look.events, "none", "not clicked twice");
+1 -1
View File
@@ -61,7 +61,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
return remove.apply(this, names);
};
b.click();
spin = svg.getAnimations()[0];
spin = svg.getAnimations({ subtree: true })[0];
}));
assert.equal(end.state, "finished", `the spin was cut off mid-turn at ${Math.round(end.time)}ms`);
assert(Number.isFinite(end.end) && Math.round(end.end) % 900 === 0, `the spin ends on a whole turn, not at ${end.end}ms`);