From 6feda44f5fc1d8da8ae5de2612519ac4cccd1900 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 02:04:46 +0000 Subject: [PATCH 1/4] test(webviews): no stylesheet breathes, blinks or pulses a status dot forever Reads every shipped webview stylesheet and lists each forever-repeating animation that fades (opacity), blinks (visibility) or pulses (scale). Each must be on an allowlist with the reason it is not a status dot. Red on base: the floating chat and Chief dock streaming dots, the Home ledger working marks and the Settings accounts busy dot still breathe. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/test/no-breathing-dots.test.ts | 241 ++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 webviews/test/no-breathing-dots.test.ts diff --git a/webviews/test/no-breathing-dots.test.ts b/webviews/test/no-breathing-dots.test.ts new file mode 100644 index 000000000000..fe6665f12fc2 --- /dev/null +++ b/webviews/test/no-breathing-dots.test.ts @@ -0,0 +1,241 @@ +// Leo 2026-10-21: "let's get rid of the blinking dot asap", "we need to guarantee the breathing dot +// doesnt come back". A status dot is still: it says what state a chat is in by its colour (and, +// under Static status indicators, its shape), never by fading or pulsing. Agent work that needs +// motion is the spinner (#23776). +// +// This reads every stylesheet the web views ship (webviews/src, not the prototypes) and lists each +// animation that repeats forever and fades (opacity), blinks (visibility) or pulses (scale). Each one +// must be on the list below, with the reason it is not a status dot. A new breathing or blinking +// dot, under any class name, fails here until someone deletes it or argues it onto the list. +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "bun:test"; + +const src = path.resolve(import.meta.dir, "../src"); + +type Loop = { file: string; keyframes: string; selector: string; props: string[] }; + +/** The loops a stylesheet may keep: file, keyframes, a piece of the rule's selector, and why. */ +const ALLOWED: { file: string; keyframes: string; selector: string; why: string; goneWith?: string }[] = [ + { + file: "agent-session/acpmux/conversation/conversation.css", + keyframes: "cmux-breathe", + selector: ".cv-spinner[data-working]", + why: "the Debug Breathing Dot working look, not the default since #23776", + goneWith: "#23870, which deletes the look and guards it in static-status-loops.test.ts", + }, + { + file: "agent-session/acpmux/conversation/conversation.css", + keyframes: "acpmux-jump-latest-dot", + selector: ".acpmux-jump-latest", + why: "Jump to latest's three working dots (#21800); Static and Reduce Motion still them", + }, + { + file: "agent-session/acpmux/inspector/inspector.css", + keyframes: "acpmux-chat-inspector-breathe", + selector: ".cv-subagent-avatar", + why: "a running subagent's avatar picture in the Inspector, not a status dot", + }, + { + file: "agent-session/acpmux/styles.css", + keyframes: "acpmux-mic-wait", + selector: ".acpmux-mic", + why: "the mic button while dictation starts", + }, + { + file: "agent-session/acpmux/newtab/screen.css", + keyframes: "nt-player-level", + selector: ".nt-player-levels", + why: "the music player's level bars", + }, + { + file: "pages/apps/styles.css", + keyframes: "cmux-breathe", + selector: ".apps-skeleton", + why: "the Apps page's loading skeleton", + }, + { + file: "pages/apps/styles.css", + keyframes: "cmux-skeleton-pulse", + selector: ".apps-skeleton", + why: "the Apps page's loading skeleton (its name once #23870 lands)", + goneWith: "not yet: #23870 renames cmux-breathe here", + }, + { + file: "pages/cloud/styles.css", + keyframes: "cloud-store-packet", + selector: ".cloud-store-art-packet", + why: "the Cloud store illustration's moving packets", + }, + { + file: "pages/home-channels/styles.css", + keyframes: "hc-writing", + selector: ".hc-writing", + why: 'Home\'s "writing" word, text rather than a dot', + }, +]; + +function stylesheets(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir).sort()) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) { + // Design prototypes, never loaded by the app. + if (path.relative(src, full) !== "prototypes") stylesheets(full, out); + } else if (name.endsWith(".css")) out.push(full); + } + return out; +} + +type Rule = { selector: string; body: string }; + +/** Style rules (nested in @media, @supports or CSS nesting included) and @keyframes bodies. */ +export function parseStylesheet(css: string): { rules: Rule[]; keyframes: Map } { + const text = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const rules: Rule[] = []; + const keyframes = new Map(); + const heads: string[] = []; + const bodies: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === "{") { + const head = text.slice(start, i).trim(); + const named = /^@(?:-webkit-)?keyframes\s+([\w-]+)/.exec(head); + if (named) { + let depth = 1; + let j = i + 1; + for (; j < text.length && depth > 0; j++) { + if (text[j] === "{") depth++; + else if (text[j] === "}") depth--; + } + keyframes.set(named[1]!, (keyframes.get(named[1]!) ?? "") + text.slice(i + 1, j - 1)); + i = j - 1; + start = j; + continue; + } + // Declarations before a nested rule belong to the enclosing rule; the selector follows them. + const cut = head.lastIndexOf(";"); + if (bodies.length && cut >= 0) bodies[bodies.length - 1] += `${head.slice(0, cut + 1)}`; + heads.push(head.slice(cut + 1).trim()); + bodies.push(""); + start = i + 1; + } else if (c === "}") { + const head = heads.pop(); + const body = (bodies.pop() ?? "") + text.slice(start, i); + if (head !== undefined && !head.startsWith("@")) { + const outer = heads.filter((h) => !h.startsWith("@")).join(" "); + rules.push({ selector: outer ? `${outer} ${head}` : head, body }); + } + start = i + 1; + } + } + return { rules, keyframes }; +} + +/** `value` split at its top-level commas (not those inside var(), cubic-bezier() or :is()). */ +function splitTopLevel(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === "(") depth++; + else if (value[i] === ")") depth--; + else if (value[i] === "," && depth === 0) { + parts.push(value.slice(from, i)); + from = i + 1; + } + } + parts.push(value.slice(from)); + return parts.map((part) => part.trim()); +} + +/** What a keyframes body moves that reads as fading, blinking or pulsing. */ +function flicker(body: string): string[] { + const props: string[] = []; + if (/(^|[;{\s])opacity\s*:/.test(body)) props.push("opacity"); + if (/(^|[;{\s])visibility\s*:/.test(body)) props.push("visibility"); + if (/(^|[;{\s])scale\s*:|scale(?:3d|X|Y)?\s*\(/.test(body)) props.push("scale"); + return props; +} + +/** Every rule that runs a forever-repeating animation whose keyframes fade, blink or pulse. */ +export function flickerLoops(files: { file: string; css: string }[]): Loop[] { + const parsed = files.map(({ file, css }) => ({ file, ...parseStylesheet(css) })); + const keyframes = new Map(); + for (const sheet of parsed) + for (const [name, body] of sheet.keyframes) keyframes.set(name, (keyframes.get(name) ?? "") + body); + const loops: Loop[] = []; + for (const { file, rules } of parsed) { + for (const { selector, body } of rules) { + const declarations = body.split(";").map((d) => d.trim()); + const shorthand = declarations + .filter((d) => /^animation\s*:/.test(d)) + .map((d) => d.replace(/^animation\s*:/, "")); + const named = declarations + .filter((d) => /^animation-name\s*:/.test(d)) + .map((d) => d.replace(/^animation-name\s*:/, "")); + const foreverCount = declarations.some((d) => /^animation-iteration-count\s*:.*infinite/.test(d)); + const runs: string[] = []; + for (const value of shorthand) + for (const part of splitTopLevel(value)) + if (/\binfinite\b/.test(part) || foreverCount) + runs.push(...part.split(/\s+/).filter((w) => keyframes.has(w))); + if (foreverCount) for (const value of named) runs.push(...splitTopLevel(value).filter((w) => keyframes.has(w))); + for (const name of new Set(runs)) { + const props = flicker(keyframes.get(name)!); + if (props.length) loops.push({ file, keyframes: name, selector: selector.replace(/\s+/g, " "), props }); + } + } + } + return loops; +} + +const shipped = stylesheets(src).map((full) => ({ file: path.relative(src, full), css: readFileSync(full, "utf8") })); +const loops = flickerLoops(shipped); +const allowed = (loop: Loop) => + ALLOWED.some((a) => a.file === loop.file && a.keyframes === loop.keyframes && loop.selector.includes(a.selector)); + +describe("no breathing or blinking dots", () => { + test("the check reads the shipped stylesheets and finds their loops", () => { + expect(shipped.length).toBeGreaterThan(50); + // A loop it must see, so an empty result can't pass by reading nothing. + expect(loops.some((l) => l.keyframes === "acpmux-mic-wait")).toBe(true); + }); + + test("it catches a breathing dot under any name, in a media query, by shorthand or longhands", () => { + const css = ` + @keyframes glow { 50% { opacity: .3 } } + @keyframes throb { to { transform: scale(1.4) } } + @keyframes flash { 50% { visibility: hidden } } + @keyframes turn { to { transform: rotate(1turn) } } + @media (prefers-reduced-motion: no-preference) { .a-dot { animation: glow 2s ease-in-out infinite alternate } } + .b-mark[data-working] { animation-name: throb; animation-iteration-count: infinite } + .c-status { color: red; .c-inner { animation: flash 1s steps(1) infinite } } + .d-spinner { animation: turn 900ms linear infinite } + .e-once { animation: glow 2s ease-out 1 }`; + const found = flickerLoops([{ file: "x.css", css }]).map( + (l) => `${l.selector} ${l.keyframes} ${l.props.join("+")}`, + ); + expect(found).toEqual([ + ".a-dot glow opacity", + ".b-mark[data-working] throb scale", + ".c-status .c-inner flash visibility", + ]); + }); + + test("no stylesheet fades, blinks or pulses a dot forever", () => { + const offenders = loops + .filter((loop) => !allowed(loop)) + .map((l) => `${l.file}: ${l.selector} runs ${l.keyframes} (${l.props.join(", ")})`); + expect(offenders).toEqual([]); + }); + + test("every allowed loop still exists, so the list can't hide a new one", () => { + const stale = ALLOWED.filter((a) => !a.goneWith) + .filter( + (a) => !loops.some((l) => l.file === a.file && l.keyframes === a.keyframes && l.selector.includes(a.selector)), + ) + .map((a) => `${a.file} ${a.keyframes}`); + expect(stale).toEqual([]); + }); +}); From e2d6d8792bfff2ff85e33cca061314acb262d5e3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 02:07:17 +0000 Subject: [PATCH 2/4] cmux NEXT: status dots are still: the floating chat, Chief dock, Home ledger and Settings accounts dots no longer breathe The floating chat and Chief dock streaming status faded in and out (acpmux-floating-breathe), the Home ledger working and running marks pulsed (lg-pulse), and the Settings cmux account dot pulsed while a sign-in ran (accounts-busy). Each is now a still dot in its state colour. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/src/agent-session/acpmux/styles.css | 12 +++++------- webviews/src/pages/home-channels/ledger.css | 16 ---------------- webviews/src/pages/settings/styles.css | 11 ----------- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index 00790ae1bbab..f4e87a6b2edb 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -540,15 +540,13 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 .acpmux-floating-title{flex:0 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:var(--text-control);font-weight:600;color:var(--agent-text)} .acpmux-floating-header .acpmux-floating-title{flex:1 1 auto} .acpmux-floating-status{flex:none;width:6px;height:6px;margin-inline-end:6px;border-radius:50%;background:var(--agent-muted)} -.acpmux-floating-status[data-status=streaming]{background:var(--agent-accent);animation:acpmux-floating-breathe 1.4s var(--agent-ease-out,ease-out) infinite alternate} +/* A working chat is a still accent dot: status dots never breathe or blink (Leo 2026-10-21; no-breathing-dots.test.ts). */ +.acpmux-floating-status[data-status=streaming]{background:var(--agent-accent)} .acpmux-floating-status[data-status=attention]{background:var(--acpmux-needs-you-mark)} -@keyframes acpmux-floating-breathe{from{opacity:1}to{opacity:.35}} -@media (prefers-reduced-motion:reduce){.acpmux-floating-status[data-status=streaming]{animation:none}} .acpmux-quick[data-floating=compact]{justify-content:stretch} .acpmux-quick-compact-bar{flex:1;min-height:0;display:flex;align-items:center} -/* Static status indicators (cx-sya4n): no breathing, and a shape per state, not color alone: a solid dot - while it works, a triangle when it needs you, in the dot's own 6px box (nothing beside it moves). */ -:root[data-status-indicators=static] .acpmux-floating-status[data-status=streaming]{animation:none} +/* Static status indicators (cx-sya4n): a shape per state, not color alone: a solid dot while it works, + a triangle when it needs you, in the dot's own 6px box (nothing beside it moves). */ :root[data-status-indicators=static] .acpmux-floating-status[data-status=attention]{border-radius:0;clip-path:polygon(50% 0,100% 100%,0 100%)} .acpmux-floating-bar{display:flex;align-items:center;gap:4px;height:100%;box-sizing:border-box;padding:12px 8px 0 4px} .acpmux-floating-bar-open{flex:1;min-width:0;display:flex;align-items:center;gap:8px;height:32px;padding:0 8px;border:0;border-radius:6px;background:none;color:inherit;font:inherit;text-align:start;cursor:default;transition:background-color var(--acpmux-motion-hover,80ms) var(--acpmux-motion-ease,ease-out)} @@ -630,7 +628,7 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 .acpmux-chief-card-copy small{color:color-mix(in srgb,var(--agent-text) 68%,var(--agent-muted));font-size:var(--text-control);line-height:var(--text-control--line-height)} .acpmux-chief-card-queue{flex:none;min-width:17px;height:17px;padding:0 4px;border-radius:6px;background:color-mix(in srgb,var(--agent-accent) 18%,transparent);color:var(--agent-text);font-size:var(--text-caption);line-height:17px;text-align:center;font-variant-numeric:tabular-nums} .acpmux-chief-dock-status{flex:none;display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;color:var(--agent-muted)} -.acpmux-chief-dock-status[data-status=streaming]{color:var(--agent-accent);animation:acpmux-floating-breathe 1.4s var(--agent-ease-out,ease-out) infinite alternate} +.acpmux-chief-dock-status[data-status=streaming]{color:var(--agent-accent)} .acpmux-chief-dock-status[data-status=attention]{color:var(--acpmux-needs-you-mark)} .acpmux-chief-dock-status[data-status=error]{color:var(--agent-danger,var(--agent-warning))} .acpmux-chief-dock-status[data-status=complete]{color:var(--agent-success,var(--acpmux-add,#3fb950))} diff --git a/webviews/src/pages/home-channels/ledger.css b/webviews/src/pages/home-channels/ledger.css index aecbd35fcbcf..3bb4f82f43fc 100644 --- a/webviews/src/pages/home-channels/ledger.css +++ b/webviews/src/pages/home-channels/ledger.css @@ -300,17 +300,6 @@ color: var(--lg-warn); font-weight: 600; } -@media (prefers-reduced-motion: no-preference) { - .lg-state.working::before, - .lg-state-mark.working { - animation: lg-pulse 1.6s ease-in-out infinite; - } -} -@keyframes lg-pulse { - 50% { - opacity: 0.35; - } -} /* ---------- Main ---------- */ .lg-main { @@ -1249,11 +1238,6 @@ border-color: var(--hc-danger); background: var(--hc-danger); } -@media (prefers-reduced-motion: no-preference) { - .lg-run-mark.running { - animation: lg-pulse 1.6s ease-in-out infinite; - } -} .lg-run-title { margin: -4px 0 4px; margin-inline-start: var(--lg-run-lead); diff --git a/webviews/src/pages/settings/styles.css b/webviews/src/pages/settings/styles.css index c7c6dbf674a0..56f3970b4026 100644 --- a/webviews/src/pages/settings/styles.css +++ b/webviews/src/pages/settings/styles.css @@ -923,17 +923,6 @@ mark { opacity: 0.4; } -/* A sign-in in progress: the dot pulses until it ends (Reduce Motion stops it, layout.css). */ -@keyframes accounts-busy { - 50% { - opacity: 0.3; - } -} - -.accounts-status[data-busy] .accounts-dot { - animation: accounts-busy calc(var(--motion-in) * 8) var(--motion-ease) infinite; -} - .accounts-body { display: flex; flex-direction: column; From 98662cd902031c39d35ae73629dd0f0775f80e80 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 03:37:35 +0000 Subject: [PATCH 3/4] cmux NEXT: the Inspector's running subagent avatars stop breathing; the dots guard catches -webkit-, any-case infinite and a repeat count set in another rule The running avatar carries its status dot, so its breathe was a breathing dot: removed, and its allowlist entry with it. The Apps skeleton entry is marked as going with #23870 (which renames it), so base + #23870 + this passes. Drops the dead Reduce Motion rule for the Chief dock status. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- .../acpmux/inspector/inspector.css | 10 -- webviews/src/agent-session/acpmux/styles.css | 2 +- webviews/test/no-breathing-dots.test.ts | 99 +++++++++++++------ 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/webviews/src/agent-session/acpmux/inspector/inspector.css b/webviews/src/agent-session/acpmux/inspector/inspector.css index 4a0a43c3a636..2e660737c507 100644 --- a/webviews/src/agent-session/acpmux/inspector/inspector.css +++ b/webviews/src/agent-session/acpmux/inspector/inspector.css @@ -652,16 +652,6 @@ button.acpmux-chat-inspector-port:focus-visible { color: var(--agent-muted); font-size: var(--text-detail); } -@media (prefers-reduced-motion: no-preference) { - .acpmux-chat-inspector-avatars .cv-subagent-avatar[data-state="running"] { - animation: acpmux-chat-inspector-breathe 2.4s ease-in-out infinite; - } -} -@keyframes acpmux-chat-inspector-breathe { - 50% { - opacity: 0.62; - } -} .acpmux-chat-inspector-subagents__list { margin-top: 4px; } diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index f4e87a6b2edb..36dae80bcc94 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -643,7 +643,7 @@ button.acpmux-edited-file:hover{background:color-mix(in srgb,var(--agent-text) 4 @media (prefers-reduced-transparency:reduce){.acpmux-chief-dock-rail,.acpmux-chief-dock-cards,.acpmux-chief-card{background:var(--surface-popup);-webkit-backdrop-filter:none;backdrop-filter:none}} :root[data-reduce-transparency] .acpmux-chief-dock-rail,:root[data-reduce-transparency] .acpmux-chief-dock-cards,:root[data-reduce-transparency] .acpmux-chief-card{background:var(--surface-popup);-webkit-backdrop-filter:none;backdrop-filter:none} @keyframes acpmux-chief-dock-attention{0%,100%{opacity:.35;transform:scale(.96)}50%{opacity:0;transform:scale(1.12)}} -@media (prefers-reduced-motion:reduce){.acpmux-chief-dock,.acpmux-chief-dock-rail,.acpmux-chief-dock-mascot,.acpmux-chief-card,.acpmux-chief-dock-cards{transition:none;animation:none}.acpmux-chief-dock-mascot img{animation:none;transition:none}.acpmux-chief-dock-status[data-status=streaming]{animation:none}} +@media (prefers-reduced-motion:reduce){.acpmux-chief-dock,.acpmux-chief-dock-rail,.acpmux-chief-dock-mascot,.acpmux-chief-card,.acpmux-chief-dock-cards{transition:none;animation:none}.acpmux-chief-dock-mascot img{animation:none;transition:none}} .acpmux-link-missing{margin:8px 24px;color:var(--agent-text);font-size:12px} .acpmux-live-chat p{margin:0} .acpmux-live-chat-command{display:block;margin:4px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:11px var(--font-mono);color:var(--agent-muted)} diff --git a/webviews/test/no-breathing-dots.test.ts b/webviews/test/no-breathing-dots.test.ts index fe6665f12fc2..b71e6b7f934f 100644 --- a/webviews/test/no-breathing-dots.test.ts +++ b/webviews/test/no-breathing-dots.test.ts @@ -15,7 +15,8 @@ const src = path.resolve(import.meta.dir, "../src"); type Loop = { file: string; keyframes: string; selector: string; props: string[] }; -/** The loops a stylesheet may keep: file, keyframes, a piece of the rule's selector, and why. */ +/** The loops a stylesheet may keep: file, keyframes, a piece of the rule's selector, and why. + * `goneWith`: the open change that removes or adds the entry, so it may match nothing. */ const ALLOWED: { file: string; keyframes: string; selector: string; why: string; goneWith?: string }[] = [ { file: "agent-session/acpmux/conversation/conversation.css", @@ -30,12 +31,6 @@ const ALLOWED: { file: string; keyframes: string; selector: string; why: string; selector: ".acpmux-jump-latest", why: "Jump to latest's three working dots (#21800); Static and Reduce Motion still them", }, - { - file: "agent-session/acpmux/inspector/inspector.css", - keyframes: "acpmux-chat-inspector-breathe", - selector: ".cv-subagent-avatar", - why: "a running subagent's avatar picture in the Inspector, not a status dot", - }, { file: "agent-session/acpmux/styles.css", keyframes: "acpmux-mic-wait", @@ -53,13 +48,14 @@ const ALLOWED: { file: string; keyframes: string; selector: string; why: string; keyframes: "cmux-breathe", selector: ".apps-skeleton", why: "the Apps page's loading skeleton", + goneWith: "#23870, which renames it cmux-skeleton-pulse", }, { file: "pages/apps/styles.css", keyframes: "cmux-skeleton-pulse", selector: ".apps-skeleton", why: "the Apps page's loading skeleton (its name once #23870 lands)", - goneWith: "not yet: #23870 renames cmux-breathe here", + goneWith: "not until #23870, which renames cmux-breathe to this", }, { file: "pages/cloud/styles.css", @@ -158,36 +154,68 @@ function flicker(body: string): string[] { return props; } -/** Every rule that runs a forever-repeating animation whose keyframes fade, blink or pulse. */ +/** A declaration's property, lowercased and without the `-webkit-` prefix, and its value. */ +function declarations(body: string): { property: string; value: string }[] { + return body + .split(";") + .map((d) => /^\s*([-\w]+)\s*:([\s\S]*)$/.exec(d)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => ({ property: m[1]!.toLowerCase().replace(/^-webkit-/, ""), value: m[2]!.trim() })); +} + +/** The classes of each selector's subject (its last compound), which name the element it styles. */ +function subjectClasses(selector: string): string[] { + return splitTopLevel(selector).flatMap((one) => { + const subject = one.split(/\s*[\s>+~]\s*/).pop() ?? ""; + return [...subject.matchAll(/\.([\w-]+)/g)].map((m) => m[1]!); + }); +} + +const INFINITE = /\binfinite\b/i; + +/** Every rule that runs a forever-repeating animation whose keyframes fade, blink or pulse. A rule + * that only makes some other rule's animation repeat forever (`animation-iteration-count: infinite`, + * or a shorthand with `infinite` and no keyframes) counts for every rule styling the same class. */ export function flickerLoops(files: { file: string; css: string }[]): Loop[] { const parsed = files.map(({ file, css }) => ({ file, ...parseStylesheet(css) })); const keyframes = new Map(); for (const sheet of parsed) for (const [name, body] of sheet.keyframes) keyframes.set(name, (keyframes.get(name) ?? "") + body); - const loops: Loop[] = []; + const names = (value: string) => value.split(/[\s,]+/).filter((w) => keyframes.has(w)); + type Run = { file: string; selector: string; name: string; forever: boolean }; + const runs: Run[] = []; + const foreverClasses = new Set(); for (const { file, rules } of parsed) { for (const { selector, body } of rules) { - const declarations = body.split(";").map((d) => d.trim()); - const shorthand = declarations - .filter((d) => /^animation\s*:/.test(d)) - .map((d) => d.replace(/^animation\s*:/, "")); - const named = declarations - .filter((d) => /^animation-name\s*:/.test(d)) - .map((d) => d.replace(/^animation-name\s*:/, "")); - const foreverCount = declarations.some((d) => /^animation-iteration-count\s*:.*infinite/.test(d)); - const runs: string[] = []; - for (const value of shorthand) - for (const part of splitTopLevel(value)) - if (/\binfinite\b/.test(part) || foreverCount) - runs.push(...part.split(/\s+/).filter((w) => keyframes.has(w))); - if (foreverCount) for (const value of named) runs.push(...splitTopLevel(value).filter((w) => keyframes.has(w))); - for (const name of new Set(runs)) { - const props = flicker(keyframes.get(name)!); - if (props.length) loops.push({ file, keyframes: name, selector: selector.replace(/\s+/g, " "), props }); + const decls = declarations(body); + const countForever = decls.some((d) => d.property === "animation-iteration-count" && INFINITE.test(d.value)); + let named = false; + for (const { property, value } of decls) { + if (property === "animation") { + for (const part of splitTopLevel(value)) { + const found = names(part); + if (found.length === 0 && INFINITE.test(part)) + for (const c of subjectClasses(selector)) foreverClasses.add(c); + for (const name of found) runs.push({ file, selector, name, forever: INFINITE.test(part) || countForever }); + named ||= found.length > 0; + } + } else if (property === "animation-name") { + for (const name of names(value)) runs.push({ file, selector, name, forever: countForever }); + named = true; + } } + if (countForever && !named) for (const c of subjectClasses(selector)) foreverClasses.add(c); } } - return loops; + const loops = new Map(); + for (const run of runs) { + const forever = run.forever || subjectClasses(run.selector).some((c) => foreverClasses.has(c)); + const props = flicker(keyframes.get(run.name)!); + if (!forever || props.length === 0) continue; + const selector = run.selector.replace(/\s+/g, " "); + loops.set(`${run.file}|${selector}|${run.name}`, { file: run.file, keyframes: run.name, selector, props }); + } + return [...loops.values()]; } const shipped = stylesheets(src).map((full) => ({ file: path.relative(src, full), css: readFileSync(full, "utf8") })); @@ -202,7 +230,7 @@ describe("no breathing or blinking dots", () => { expect(loops.some((l) => l.keyframes === "acpmux-mic-wait")).toBe(true); }); - test("it catches a breathing dot under any name, in a media query, by shorthand or longhands", () => { + test("it catches a breathing dot under any name, in a media query, nested, -webkit-, by shorthand or longhands, and with its repeat count in another rule", () => { const css = ` @keyframes glow { 50% { opacity: .3 } } @keyframes throb { to { transform: scale(1.4) } } @@ -212,7 +240,14 @@ describe("no breathing or blinking dots", () => { .b-mark[data-working] { animation-name: throb; animation-iteration-count: infinite } .c-status { color: red; .c-inner { animation: flash 1s steps(1) infinite } } .d-spinner { animation: turn 900ms linear infinite } - .e-once { animation: glow 2s ease-out 1 }`; + .e-once { animation: glow 2s ease-out 1 } + .f-dot { -webkit-animation: glow 2s INFINITE } + .g-dot { animation: glow 2s ease-out } + .g-row .g-dot[data-on] { animation-iteration-count: infinite } + .h-dot { -webkit-animation-name: throb } + .h-dot.on { -webkit-animation-iteration-count: Infinite } + .i-dot { animation: 2s infinite } + .i-dot[data-on] { animation-name: flash }`; const found = flickerLoops([{ file: "x.css", css }]).map( (l) => `${l.selector} ${l.keyframes} ${l.props.join("+")}`, ); @@ -220,6 +255,10 @@ describe("no breathing or blinking dots", () => { ".a-dot glow opacity", ".b-mark[data-working] throb scale", ".c-status .c-inner flash visibility", + ".f-dot glow opacity", + ".g-dot glow opacity", + ".h-dot throb scale", + ".i-dot[data-on] flash visibility", ]); }); From 6cf03d272fac71bbe27010f5860f64edf223008d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 03:51:05 +0000 Subject: [PATCH 4/4] test(webviews): the dots guard lists #23995's running wave Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16027-99fc-73f0-9482-5a4babfe1b0a&host=cosmic-gray --- webviews/test/no-breathing-dots.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webviews/test/no-breathing-dots.test.ts b/webviews/test/no-breathing-dots.test.ts index b71e6b7f934f..b624409437f6 100644 --- a/webviews/test/no-breathing-dots.test.ts +++ b/webviews/test/no-breathing-dots.test.ts @@ -43,6 +43,12 @@ const ALLOWED: { file: string; keyframes: string; selector: string; why: string; selector: ".nt-player-levels", why: "the music player's level bars", }, + { + file: "ui/wave.css", + keyframes: "cmux-wave", + selector: ".acpmux-wave-item", + why: "RUNNING-WAVE (#23995): one shimmer sweeping across the Inspector's running subagent avatars, which replaced their out-of-step breathing; Static, Reduce Motion and the motion gate stop it", + }, { file: "pages/apps/styles.css", keyframes: "cmux-breathe",