From 97f825d560e2ce89ae8d11130c86511a2f920875 Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 9 Oct 2026 01:46:44 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20spinners=20turn=20round=20their=20own=20?= =?UTF-8?q?centre:=20the=20drawing=20turns=20inside=20the=20svg,=20round?= =?UTF-8?q?=20the=20viewBox's=20centre,=20since=20WebKit=20turned=20a=20wh?= =?UTF-8?q?ole=20svg=20on=20a=20pixel-snapped=20layer=20and=20its=20ring?= =?UTF-8?q?=20circled=20a=20point=20~1.3px=20off=20(Agents'=20"Updating?= =?UTF-8?q?=E2=80=A6",=20Sync,=20Save,=20the=20quota=20and=20usage=20refre?= =?UTF-8?q?shes,=20the=20agent=20fix)=20(yetone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/gui/assets/app.css | 20 +++++++++++-------- internal/gui/assets/app.js | 9 +++++---- internal/gui/tests/cli-update.test.cjs | 3 +++ .../gui/tests/provider-save-busy.test.cjs | 2 +- internal/gui/tests/sync-spin.test.cjs | 2 +- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index b38f5431..ace457f4 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 459f7f90..4835c386 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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(); } }; diff --git a/internal/gui/tests/cli-update.test.cjs b/internal/gui/tests/cli-update.test.cjs index c1a18e0d..779ddaed 100644 --- a/internal/gui/tests/cli-update.test.cjs +++ b/internal/gui/tests/cli-update.test.cjs @@ -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); diff --git a/internal/gui/tests/provider-save-busy.test.cjs b/internal/gui/tests/provider-save-busy.test.cjs index 6fb43065..65420ac8 100644 --- a/internal/gui/tests/provider-save-busy.test.cjs +++ b/internal/gui/tests/provider-save-busy.test.cjs @@ -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"); diff --git a/internal/gui/tests/sync-spin.test.cjs b/internal/gui/tests/sync-spin.test.cjs index bebd1f54..1ff5c5e4 100644 --- a/internal/gui/tests/sync-spin.test.cjs +++ b/internal/gui/tests/sync-spin.test.cjs @@ -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`);