diff --git a/scripts/theme-report.test.mts b/scripts/theme-report.test.mts
index a5da605a..c1cf6197 100644
--- a/scripts/theme-report.test.mts
+++ b/scripts/theme-report.test.mts
@@ -35,6 +35,7 @@ import {
snapshotBaseline,
requireOutsideGit,
type LiveResult,
+ type SelectorRun,
contrastRatio,
DERIVED_COLORS,
fillCanonical,
@@ -47,6 +48,10 @@ import {
resolveScheme,
schemeVars,
readSettingsControls,
+ probeSelectors,
+ swapPlayback,
+ lostSelectors,
+ SELECTORS_FILE,
} from "./theme-report.ts";
describe("screenshot region", () => {
@@ -73,6 +78,156 @@ describe("screenshot region", () => {
});
});
+describe("pinned playback", () => {
+ const fakeClient = (failPlay = false) => {
+ const log: string[] = [];
+ const state = {
+ item: { uri: "spotify:track:mine" },
+ context: { uri: "spotify:album:mine" },
+ positionAsOfTimestamp: 1234,
+ isPaused: false,
+ };
+ let muted = false;
+ (globalThis as any).window = {
+ Spicetify: {
+ Player: { getMute: () => muted, setMute: (m: boolean) => (log.push(`mute:${m}`), (muted = m)) },
+ Platform: {
+ PlayerAPI: {
+ getState: () => state,
+ play: async (ctx: { uri: string }, _: object, opts: { skipTo?: { uri: string } }) => {
+ if (failPlay) throw new Error("offline");
+ log.push(`play:${opts.skipTo?.uri ?? ctx.uri}`);
+ state.item = { uri: opts.skipTo?.uri ?? ctx.uri };
+ state.isPaused = false;
+ },
+ pause: async () => (log.push("pause"), (state.isPaused = true)),
+ seekTo: async (ms: number) => (log.push(`seek:${ms}`), (state.positionAsOfTimestamp = ms)),
+ },
+ },
+ },
+ };
+ return { log, state, isMuted: () => muted };
+ };
+ after(() => delete (globalThis as any).window);
+
+ it("loads the track muted and paused, and reports what was playing", async () => {
+ const client = fakeClient();
+ const previous = await swapPlayback({ uri: "spotify:track:fixed" }, 0, true);
+ assert.deepEqual(previous, {
+ uri: "spotify:track:mine",
+ context: "spotify:album:mine",
+ position: 1234,
+ paused: false,
+ });
+ assert.deepEqual(client.log, ["mute:true", "play:spotify:track:fixed", "pause", "seek:0", "mute:false"]);
+ assert.equal(client.state.isPaused, true);
+ });
+
+ it("puts the mute state back when the track cannot be loaded", async () => {
+ const client = fakeClient(true);
+ await assert.rejects(swapPlayback({ uri: "spotify:track:fixed" }, 0, true), /offline/);
+ assert.equal(client.isMuted(), false);
+ });
+});
+
+describe("selector binding", () => {
+ const probe = (html: string, css: string, key?: string) => {
+ const window = new Window();
+ try {
+ window.document.body.innerHTML = html;
+ const args = [css, key].filter((x) => x !== undefined).map((x) => JSON.stringify(x));
+ return structuredClone(window.eval(`(${probeSelectors.toString()})(${args.join(",")})`));
+ } finally {
+ window.close();
+ }
+ };
+
+ it("reports which selector parts match, ignoring interaction states and pseudo-elements", () => {
+ const result = probe(
+ ``,
+ `
+ .gone .cover-art, [data-testid="cover-art-button"] .cover-art { width: 1px }
+ [data-testid="cover-art-button"]:hover, .cover-art:focus-within { color: red }
+ .cover-art::after, form .search:not(:placeholder-shown) { content: "" }
+ @media (min-width: 1px) { .also-gone { color: red } }`,
+ );
+ assert.deepEqual(result.matched, [
+ '[data-testid="cover-art-button"] .cover-art',
+ '[data-testid="cover-art-button"]:hover',
+ ".cover-art:focus-within",
+ ".cover-art::after",
+ "form .search:not(:placeholder-shown)",
+ ]);
+ assert.deepEqual(
+ result.all.filter((s: string) => !result.matched.includes(s)),
+ [".gone .cover-art", ".also-gone"],
+ );
+ });
+
+ it("splits selector lists only on top-level commas", () => {
+ const result = probe(
+ `
`,
+ `:is(.a, :not(.b)) .c, [aria-label="Play, pause"].x { color: red }`,
+ );
+ assert.deepEqual(result, {
+ all: [":is(.a, :not(.b)) .c", '[aria-label="Play, pause"].x'],
+ matched: [":is(.a, :not(.b)) .c", '[aria-label="Play, pause"].x'],
+ skipped: 0,
+ });
+ });
+
+ it("names parts after the source stylesheet when the served one only differs in class names", () => {
+ const result = probe(
+ ``,
+ ".hashed123 { color: red }",
+ ".main-stable { color: red }",
+ );
+ assert.deepEqual(result, { all: [".main-stable"], matched: [".main-stable"], skipped: 0 });
+ });
+
+ it("keeps the served names when the source does not line up rule for rule", () => {
+ const result = probe(
+ `
`,
+ ".hashed123 span { color: red }",
+ "[data-testid=x] { color: red }",
+ );
+ assert.deepEqual(result.all, [".hashed123 span"]);
+ });
+
+ it("flags only selectors that matched before, still exist, and match nothing now on the same routes", () => {
+ const run = (themes: SelectorRun["themes"]): SelectorRun => ({ playing: true, themes });
+ const baseline = run({
+ starry: {
+ "/": { all: [".a", ".b", ".c", ".d"], matched: [".a", ".b", ".c"], skipped: 0 },
+ "/search": { all: [".s"], matched: [".s"], skipped: 0 },
+ },
+ gone: { "/": { all: [".x"], matched: [".x"], skipped: 0 } },
+ });
+ const current = run({
+ starry: { "/": { all: [".a", ".b", ".d", ".s"], matched: [".a"], skipped: 0 } },
+ fresh: { "/": { all: [".y"], matched: [], skipped: 0 } },
+ });
+ assert.deepEqual(lostSelectors(baseline, current), [{ theme: "starry", selector: ".b" }]);
+ });
+
+ it("accepting a run keeps its selector results as the next baseline", () => {
+ const current = mkdtempSync(path.join(tmpdir(), "theme-report-current-"));
+ const baseline = path.join(mkdtempSync(path.join(tmpdir(), "theme-report-baseline-")), "baseline");
+ writeFileSync(path.join(current, SELECTORS_FILE), "{}");
+ writeFileSync(path.join(current, "frame.png"), "");
+ writeFileSync(path.join(current, "shots.json"), "{}");
+ try {
+ accept(current, baseline);
+ assert.ok(existsSync(path.join(baseline, SELECTORS_FILE)));
+ assert.ok(existsSync(path.join(baseline, "frame.png")));
+ assert.ok(!existsSync(path.join(baseline, "shots.json")));
+ } finally {
+ rmSync(current, { recursive: true, force: true });
+ rmSync(path.dirname(baseline), { recursive: true, force: true });
+ }
+ });
+});
+
describe("settings control capture", () => {
for (const encore of ["e-10750", "e-10800"]) {
it(`includes links and both toggle states with ${encore} classes`, async () => {
diff --git a/scripts/theme-report.ts b/scripts/theme-report.ts
index 8923e9d8..2b5a94e6 100644
--- a/scripts/theme-report.ts
+++ b/scripts/theme-report.ts
@@ -14,6 +14,7 @@
*
* node scripts/theme-report.ts capture, compare, write
* node scripts/theme-report.ts --accept make this run the baseline
+ * node scripts/theme-report.ts --track spotify:track:… show this track paused instead of the default
* node scripts/theme-report.ts --no-capture rebuild the page from disk
* node scripts/theme-report.ts --no-open write without opening a browser
* node scripts/theme-report.ts --themes flow just one
@@ -25,7 +26,7 @@
* Spotify must be running with --remote-debugging-port=9229. Output defaults
* to ../scratchpad/theme-shots, which is outside every repo.
*
- * Four checks, and they cover different things on purpose:
+ * Five checks, and they cover different things on purpose:
*
* what moved each frame against the last accepted run, so an intended
* change is reviewed once and everything else stays quiet
@@ -35,6 +36,10 @@
* ever shows, which is the only place they are checked at all
* animation a surface that never settles cannot be tracked, and saying
* so beats reporting it as changed every run
+ * selectors each theme selector that matched an element on some route in
+ * the accepted run and matches nothing now; it names the rule
+ * that broke, and works for animated themes too, which a
+ * whole-theme repaint share cannot do
*/
import {
@@ -445,6 +450,113 @@ export function readSettingsControls() {
};
}
+/**
+ * Runs in the real client. Splits a theme stylesheet into selector parts and
+ * reports which of them match an element in the current document. Interaction
+ * states and pseudo-elements are stripped first, so a :hover rule counts as
+ * bound when its element exists. Parts the engine cannot query are counted in
+ * `skipped` rather than checked. `keyCss` names the parts: the theme's source,
+ * whose stable class names do not change between Spotify builds the way the
+ * served (css-mapped) ones do. It is only used when every part has the same
+ * shape as its served counterpart once class names are blanked out.
+ */
+export function probeSelectors(servedCss: string, keyCss: string = servedCss) {
+ const split = (list: string) => {
+ const out: string[] = [];
+ let depth = 0;
+ let quote = "";
+ let start = 0;
+ for (let i = 0; i < list.length; i++) {
+ const c = list[i];
+ if (quote) {
+ if (c === "\\") i++;
+ else if (c === quote) quote = "";
+ } else if (c === "'" || c === '"') quote = c;
+ else if (c === "(" || c === "[") depth++;
+ else if (c === ")" || c === "]") depth--;
+ else if (c === "," && depth === 0) {
+ out.push(list.slice(start, i).trim());
+ start = i + 1;
+ }
+ }
+ out.push(list.slice(start).trim());
+ return out;
+ };
+ const parts = (css: string) => {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(css);
+ const out: string[] = [];
+ const visit = (rules: CSSRuleList) => {
+ for (const rule of Array.from(rules)) {
+ const selectorText = (rule as CSSStyleRule).selectorText;
+ if (selectorText) out.push(...split(selectorText));
+ const nested = (rule as CSSGroupingRule).cssRules;
+ if (nested) visit(nested);
+ }
+ };
+ visit(sheet.cssRules);
+ return out;
+ };
+ const served = parts(servedCss);
+ const keyed = parts(keyCss);
+ // The css-map only renames classes, so matching parts have the same shape once classes are blanked.
+ const shape = (part: string) => part.replace(/\.[\w-]+/g, ".c");
+ const aligned = keyed.length === served.length && keyed.every((part, i) => shape(part) === shape(served[i]));
+ const names = aligned ? keyed : served;
+ const all: string[] = [];
+ const matched: string[] = [];
+ let skipped = 0;
+ served.forEach((part, i) => {
+ const query =
+ part
+ .replace(/::?(?:before|after|placeholder|selection|backdrop|marker|-webkit-[\w-]+)(?![\w-])/g, "")
+ .replace(/:(?:focus-visible|focus-within|hover|focus|active|visited)(?![\w-])/g, "")
+ .trim() || "*";
+ let hit: boolean;
+ try {
+ hit = document.querySelector(query) !== null;
+ } catch {
+ skipped++;
+ return;
+ }
+ all.push(names[i]);
+ if (hit) matched.push(names[i]);
+ });
+ return { all, matched, skipped };
+}
+
+export type SelectorProbe = ReturnType;
+
+/** Per theme, per route: what the theme's stylesheet selected there. */
+export interface SelectorRun {
+ /** Whether music was playing; selectors that depend on playback state flip with it. */
+ playing: boolean | null;
+ themes: Record>;
+}
+
+/**
+ * Selector parts that matched an element on a route in the baseline run and
+ * match nothing on that run's routes now. Only routes captured by both runs
+ * count, so a partial run is not reported against routes it never visited,
+ * and a part the theme no longer has is a change to the theme, not a loss.
+ */
+export function lostSelectors(baseline: SelectorRun, current: SelectorRun): { theme: string; selector: string }[] {
+ const lost: { theme: string; selector: string }[] = [];
+ for (const [theme, nowRoutes] of Object.entries(current.themes)) {
+ const beforeRoutes = baseline.themes[theme];
+ if (!beforeRoutes) continue;
+ const routes = Object.keys(nowRoutes).filter((route) => route in beforeRoutes);
+ const union = (runs: Record, key: "all" | "matched") =>
+ new Set(routes.flatMap((route) => runs[route][key]));
+ const present = union(nowRoutes, "all");
+ const matched = union(nowRoutes, "matched");
+ for (const selector of union(beforeRoutes, "matched")) {
+ if (present.has(selector) && !matched.has(selector)) lost.push({ theme, selector });
+ }
+ }
+ return lost;
+}
+
export interface LiveShot {
theme: string;
themeVersion?: string;
@@ -474,6 +586,8 @@ export interface LiveResult {
viewport?: { width: number; height: number; dpr: number };
selector?: string;
suite?: "classmaps";
+ /** What each theme's stylesheet selected on each captured route. */
+ selectors?: SelectorRun;
cleanupVerified?: boolean;
navigation?: string;
}
@@ -675,6 +789,9 @@ const STABILISE = `
Removed rather than paused: pausing freezes each one wherever it had
got to, which differs every run and diffs against itself. */
*, *::before, *::after { animation: none !important; transition: none !important; }
+ /* Canvas loops and music videos keep playing while the track is paused,
+ and a paused video stops on a different frame every run. */
+ video { visibility: hidden !important; }
\`;
document.head.appendChild(s);
}
@@ -702,8 +819,53 @@ async function settle(cdp: Cdp, before: string): Promise {
return main;
}
+/** Loaded paused into the player for every capture, so the playbar shows the same thing each run. */
+export const REPORT_TRACK = "spotify:track:0DiWol3AO6WpXZgp0goxAV";
+
+/**
+ * Runs in the real client. Loads `target` (from its context when given) with
+ * the client muted, pauses it the moment it becomes the current item when
+ * `paused`, seeks to `position`, and puts the mute state back. Returns what
+ * was playing before, for a later call to restore.
+ */
+export async function swapPlayback(target: { uri: string; context?: string }, position: number, paused: boolean) {
+ const api = window.Spicetify.Platform.PlayerAPI;
+ const player = window.Spicetify.Player;
+ const until = async (test: () => boolean) => {
+ const end = Date.now() + 5000;
+ while (Date.now() < end && !test()) await new Promise((r) => setTimeout(r, 10));
+ };
+ const state = api.getState();
+ const previous = {
+ uri: state.item?.uri as string | undefined,
+ context: state.context?.uri as string | undefined,
+ position: state.positionAsOfTimestamp as number,
+ paused: state.isPaused as boolean,
+ };
+ const muted = player.getMute();
+ player.setMute(true);
+ try {
+ await api.play(
+ { uri: target.context ?? target.uri },
+ {},
+ target.context ? { skipTo: { uri: target.uri } } : {},
+ );
+ await until(() => api.getState().item?.uri === target.uri);
+ if (paused) {
+ await api.pause();
+ await until(() => api.getState().isPaused);
+ }
+ await api.seekTo(position);
+ } finally {
+ player.setMute(muted);
+ }
+ return previous;
+}
+
export interface LiveOptions {
outDir: string;
+ /** Track to show paused in the playbar; the previous playback is restored afterwards. */
+ track?: string;
selector?: string;
port?: number;
themes?: string[];
@@ -742,6 +904,39 @@ export async function captureLive(opts: LiveOptions): Promise {
const routes = opts.routes?.length ? opts.routes : Object.keys(ROUTES);
const shots: LiveShot[] = [];
const failures: LiveFailure[] = [];
+ const selectors: SelectorRun = {
+ playing: await cdp.eval(`return window.Spicetify?.Player?.isPlaying?.() ?? null;`),
+ themes: {},
+ };
+
+ /** Records which of the active theme's selector parts match something on this route. */
+ const probeTheme = async (theme: string, route: string) => {
+ // Source names only line up when the client runs the repo's version of the theme.
+ const dir = path.join(REPO, "themes", theme);
+ const sourceVersion = existsSync(path.join(dir, "metadata.json"))
+ ? JSON.parse(readFileSync(path.join(dir, "metadata.json"), "utf8")).version
+ : null;
+ const source =
+ sourceVersion && sourceVersion === installed.get(theme) && existsSync(path.join(dir, "index.css"))
+ ? readFileSync(path.join(dir, "index.css"), "utf8")
+ : null;
+ try {
+ const probe = await cdp.eval(`
+ const registry = window.Spicetify.Modules.registry;
+ const entry = registry?.modules?.get?.(${JSON.stringify(theme)})?.entries?.css;
+ if (!entry) return null;
+ let served = registry.localFiles?.get?.(${JSON.stringify(theme)})?.[entry];
+ if (served === undefined) {
+ const res = await fetch(${JSON.stringify(`/modules/${theme}/`)} + entry);
+ if (!res.ok) throw new Error("stylesheet fetch returned " + res.status);
+ served = await res.text();
+ }
+ return (${probeSelectors.toString()})(served, ${JSON.stringify(source)} ?? served);`);
+ if (probe) (selectors.themes[theme] ??= {})[route] = probe;
+ } catch (e) {
+ failures.push({ theme, error: `selector probe on ${route}: ${(e as Error).message.slice(0, 80)}` });
+ }
+ };
const clientVersion = await cdp.eval(
`return navigator.userAgent.match(/Spotify\\/(\\S+)/)?.[1] ?? null;`,
@@ -772,6 +967,7 @@ export async function captureLive(opts: LiveOptions): Promise {
await cdp.eval(STABILISE);
const file = path.join(opts.outDir, `${label}--${surface}.png`);
const stable = await cdp.shootStable(file, { selector: opts.selector });
+ if (label !== UNTHEMED) await probeTheme(label, route);
const settingsControls =
route === "/preferences"
? await cdp.eval>(
@@ -792,7 +988,20 @@ export async function captureLive(opts: LiveOptions): Promise {
}
};
+ let previousPlayback: Awaited> | null = null;
try {
+ if (opts.track) {
+ try {
+ previousPlayback = await cdp.eval>>(
+ `return await (${swapPlayback.toString()})(${JSON.stringify({ uri: opts.track })}, 0, true);`,
+ );
+ } catch (e) {
+ failures.push({
+ theme: "(playback)",
+ error: `could not load ${opts.track}: ${(e as Error).message.slice(0, 80)}`,
+ });
+ }
+ }
if (opts.includeUnthemed && known.active) {
const before = await mainColour(cdp);
// Transient unload, not disable: disable now writes the persisted
@@ -833,6 +1042,14 @@ export async function captureLive(opts: LiveOptions): Promise {
await tour(theme, scheme, main);
}
} finally {
+ if (previousPlayback?.uri) {
+ const { uri, context, position, paused } = previousPlayback;
+ await cdp
+ .eval(
+ `return await (${swapPlayback.toString()})(${JSON.stringify({ uri, context })}, ${position}, ${paused});`,
+ )
+ .catch((e: Error) => console.error(`could not restore playback: ${e.message}`));
+ }
if (restoreTo) {
await cdp
.eval(
@@ -848,7 +1065,7 @@ export async function captureLive(opts: LiveOptions): Promise {
cdp.close();
}
- return { shots, failures, restored: restoreTo, clientVersion, viewport, selector: opts.selector };
+ return { shots, failures, restored: restoreTo, clientVersion, viewport, selector: opts.selector, selectors };
}
export const CLASSMAP_STATES = [
@@ -1557,12 +1774,14 @@ export function checkBinding(currentDir: string, shots: LiveShot[], floor = BIND
return rows;
}
+export const SELECTORS_FILE = "selectors.json";
+
/** Promote the run just taken to be what the next one is measured against. */
export function accept(currentDir: string, baselineDir: string): number {
requireOutsideGit(baselineDir);
rmSync(baselineDir, { recursive: true, force: true });
mkdirSync(baselineDir, { recursive: true });
- const files = readdirSync(currentDir).filter((f) => f.endsWith(".png"));
+ const files = readdirSync(currentDir).filter((f) => f.endsWith(".png") || f === SELECTORS_FILE);
for (const f of files) copyFileSync(path.join(currentDir, f), path.join(baselineDir, f));
return files.length;
}
@@ -1574,11 +1793,17 @@ function page(opts: {
changes: ShotChange[];
findings: Finding[];
bindings: Binding[];
+ lost: { theme: string; selector: string }[];
+ selectorBaseline: boolean;
+ playbackDiffers: boolean;
live: LiveResult;
capturedAt: string;
hasBaseline: boolean;
}): string {
- const { changes, findings, bindings, live, capturedAt, hasBaseline } = opts;
+ const { changes, findings, bindings, lost, selectorBaseline, playbackDiffers, live, capturedAt, hasBaseline } =
+ opts;
+ const lostByTheme = new Map();
+ for (const l of lost) lostByTheme.set(l.theme, [...(lostByTheme.get(l.theme) ?? []), l.selector]);
const bindingByTheme = new Map();
for (const b of bindings) bindingByTheme.set(b.theme, Math.max(bindingByTheme.get(b.theme) ?? 0, b.ratio));
const unbound = [...bindingByTheme].filter(([, r]) => r < BINDING_FLOOR).map(([t]) => t);
@@ -1623,8 +1848,18 @@ function page(opts: {
${esc(first?.scheme ?? "no scheme")} · ${esc(first?.main ?? "")}
${bindingByTheme.has(theme) ? `repaints ${(bindingByTheme.get(theme)! * 100).toFixed(0)}% of the bare client${bindingByTheme.get(theme)! < BINDING_FLOOR ? " — not binding" : ""}` : ""}
${worst !== null ? `${issues.length} contrast issue${issues.length === 1 ? "" : "s"}, worst ${worst.toFixed(2)}:1` : ""}
+ ${lostByTheme.has(theme) ? `${lostByTheme.get(theme)!.length} selector${lostByTheme.get(theme)!.length === 1 ? "" : "s"} stopped matching` : ""}
${shots.map(shotFigure).join("")}
+ ${
+ lostByTheme.has(theme)
+ ? `Matched an element in the baseline run, match nothing on the same routes now${playbackDiffers ? " (playback state differs from the baseline, so playback-dependent rules may appear)" : ""}
+ ${lostByTheme
+ .get(theme)!
+ .map((selector) => `${esc(selector)} `)
+ .join("")}
`
+ : ""
+ }
${
issues.length
? `${issues.length} pair${issues.length === 1 ? "" : "s"} under ${MIN_RATIO}:1
@@ -1703,6 +1938,7 @@ footer{margin-top:46px;padding-top:18px;border-top:1px solid var(--rule);color:v
${findings.length}contrast issues
${animated.length ? `${animated.length}animated, not tracked
` : ""}
${unbound.length}themes not binding
+ ${selectorBaseline ? lost.length : "–"}${selectorBaseline ? "selectors stopped matching" : "selectors, no baseline yet"}
${
@@ -1779,6 +2015,9 @@ async function main(): Promise {
live = JSON.parse(readFileSync(path.join(outDir, "shots.json"), "utf8"));
console.log(`reusing ${live.shots.length} frames already on disk`);
} else {
+ // Frames left by an earlier run, say of a theme no longer installed,
+ // would otherwise be compared and promoted by --accept with this run.
+ for (const f of readdirSync(currentDir)) if (f.endsWith(".png")) rmSync(path.join(currentDir, f));
console.log("capturing from the live client…");
live = suite
? await captureClassmaps({ outDir: currentDir, port: flag("port") ? Number(flag("port")) : undefined })
@@ -1790,6 +2029,7 @@ async function main(): Promise {
routes: list("routes"),
candidates: themeIds(),
includeUnthemed: true,
+ track: flag("track") ?? REPORT_TRACK,
});
writeFileSync(path.join(outDir, "shots.json"), JSON.stringify(live, null, "\t") + "\n");
for (const f of live.failures) console.error(` FAILED ${f.theme}: ${f.error}`);
@@ -1812,15 +2052,42 @@ async function main(): Promise {
const findings = auditAll(path.join(REPO, "themes")).findings;
const bindings = live.selector ? [] : checkBinding(currentDir, live.shots);
const unbound = bindings.filter((b) => !b.bound);
+ // A run without selector results must not leave an older run's file for --accept to promote.
+ if (live.selectors) {
+ writeFileSync(path.join(currentDir, SELECTORS_FILE), JSON.stringify(live.selectors, null, "\t") + "\n");
+ } else {
+ rmSync(path.join(currentDir, SELECTORS_FILE), { force: true });
+ }
+ const baselineSelectorsFile = path.join(baselineDir, SELECTORS_FILE);
+ const selectorBaseline: SelectorRun | null =
+ live.selectors && existsSync(baselineSelectorsFile)
+ ? JSON.parse(readFileSync(baselineSelectorsFile, "utf8"))
+ : null;
+ const lost = selectorBaseline && live.selectors ? lostSelectors(selectorBaseline, live.selectors) : [];
+ const playbackDiffers =
+ !!selectorBaseline &&
+ selectorBaseline.playing !== null &&
+ live.selectors?.playing != null &&
+ selectorBaseline.playing !== live.selectors.playing;
writeFileSync(
path.join(outDir, "index.html"),
- page({ changes, findings, bindings, live, capturedAt: dateStamp(), hasBaseline }),
+ page({
+ changes,
+ findings,
+ bindings,
+ lost,
+ selectorBaseline: !!selectorBaseline,
+ playbackDiffers,
+ live,
+ capturedAt: dateStamp(),
+ hasBaseline,
+ }),
);
writeFileSync(
path.join(outDir, "report.json"),
JSON.stringify(
- { changes, findings, bindings, failures: live.failures, incomplete, baselineSource },
+ { changes, findings, bindings, lostSelectors: lost, failures: live.failures, incomplete, baselineSource },
null,
"\t",
) + "\n",
@@ -1842,6 +2109,13 @@ async function main(): Promise {
}
}
if (unbound.length) console.log(`not binding: ${[...new Set(unbound.map((u) => u.theme))].join(", ")}`);
+ if (!selectorBaseline) console.log("selectors: no baseline yet, nothing compared");
+ else {
+ console.log(`selectors that matched in the baseline and match nothing now: ${lost.length}`);
+ if (playbackDiffers)
+ console.log(" playback state differs from the baseline; playback-dependent rules may be listed");
+ for (const l of lost) console.log(` ${l.theme}: ${l.selector}`);
+ }
console.log(hasBaseline ? `changed since baseline: ${moved.length}` : "no baseline yet, nothing compared");
for (const c of moved) console.log(` ${c.shot.theme}/${c.shot.surface}: ${(c.changedRatio * 100).toFixed(2)}%`);
diff --git a/themes/dribbblish/index.css b/themes/dribbblish/index.css
index 61452f57..7ca0d233 100644
--- a/themes/dribbblish/index.css
+++ b/themes/dribbblish/index.css
@@ -481,7 +481,8 @@ html.spicetify-themed .Root__top-container {
}
/* customise player cover art */
-.main-nowPlayingWidget-coverArt .cover-art {
+.main-nowPlayingWidget-coverArt .cover-art,
+[data-testid="cover-art-button"] .cover-art {
width: var(--bar-cover-art-size) !important;
height: var(--bar-cover-art-size) !important;
background-color: transparent;
diff --git a/themes/dribbblish/metadata.json b/themes/dribbblish/metadata.json
index 93bd2e50..f26ce63c 100644
--- a/themes/dribbblish/metadata.json
+++ b/themes/dribbblish/metadata.json
@@ -1,7 +1,7 @@
{
"name": "dribbblish",
"kind": "theme",
- "version": "0.1.3",
+ "version": "0.1.4",
"authors": ["khanhas", "harbassan"],
"description": "Compact card-based redesign with rounded art and nineteen schemes",
"entries": {
diff --git a/themes/starry-night/index.css b/themes/starry-night/index.css
index 5a556d2d..2eed9f01 100644
--- a/themes/starry-night/index.css
+++ b/themes/starry-night/index.css
@@ -168,7 +168,8 @@
width: 100%;
}
-.main-nowPlayingWidget-coverArt .cover-art {
+.main-nowPlayingWidget-coverArt .cover-art,
+[data-testid="cover-art-button"] .cover-art {
height: 220px !important;
width: 220px !important;
margin: auto;
@@ -176,7 +177,8 @@
box-shadow: 0 0 0px var(--spice-rgb-shadow);
}
-.main-nowPlayingWidget-coverArt .cover-art img {
+.main-nowPlayingWidget-coverArt .cover-art img,
+[data-testid="cover-art-button"] .cover-art img {
border-radius: 50%;
animation: spin 25s linear infinite;
animation-play-state: paused;
@@ -187,6 +189,10 @@
[data-testid="now-playing-bar"]:has([data-testid="control-button-playpause"][aria-label="Pause"])
.main-nowPlayingWidget-coverArt
.cover-art
+ img,
+[data-testid="now-playing-bar"]:has([data-testid="control-button-playpause"][aria-label="Pause"])
+ [data-testid="cover-art-button"]
+ .cover-art
img {
animation-play-state: running !important;
}
@@ -207,7 +213,8 @@
background-color: #00000000;
}
-.main-nowPlayingWidget-coverArt {
+.main-nowPlayingWidget-coverArt,
+[data-testid="cover-art-button"] > div {
width: 100%;
height: 100%;
padding-top: 5px;
diff --git a/themes/starry-night/metadata.json b/themes/starry-night/metadata.json
index 8b0db507..c94f1017 100644
--- a/themes/starry-night/metadata.json
+++ b/themes/starry-night/metadata.json
@@ -1,7 +1,7 @@
{
"name": "starry-night",
"kind": "theme",
- "version": "0.1.4",
+ "version": "0.1.5",
"authors": ["Brandon Chen", "Julissa Laignelet"],
"description": "Night sky backdrop with twinkling stars and shooting stars, in seven skies",
"entries": {
diff --git a/themes/text/index.css b/themes/text/index.css
index 943c5220..b1806dd1 100644
--- a/themes/text/index.css
+++ b/themes/text/index.css
@@ -849,7 +849,8 @@ form .main-topBar-searchBar:placeholder-shown {
}
/* cover art */
-.main-nowPlayingWidget-coverArt .cover-art {
+.main-nowPlayingWidget-coverArt .cover-art,
+[data-testid="cover-art-button"] .cover-art {
height: 32px !important;
width: 32px !important;
}
diff --git a/themes/text/metadata.json b/themes/text/metadata.json
index 6e61a6e7..10b815f0 100644
--- a/themes/text/metadata.json
+++ b/themes/text/metadata.json
@@ -1,7 +1,7 @@
{
"name": "text",
"kind": "theme",
- "version": "0.1.6",
+ "version": "0.1.7",
"authors": ["darkthemer"],
"description": "Mimics the terminal look of spotify-tui, with 21 schemes",
"entries": {
diff --git a/themes/turntable/index.css b/themes/turntable/index.css
index 91361689..e82c5e92 100644
--- a/themes/turntable/index.css
+++ b/themes/turntable/index.css
@@ -84,7 +84,8 @@
box-shadow: 0 0 5px rgba(200, 200, 200, 0.4);
}
-.main-nowPlayingWidget-coverArt .cover-art {
+.main-nowPlayingWidget-coverArt .cover-art,
+[data-testid="cover-art-button"] .cover-art {
width: 62px !important;
height: 62px !important;
box-shadow: unset;
@@ -320,11 +321,13 @@
module toggles the root class, so the client is free to re-render the
cover art without losing the state. */
.main-nowPlayingWidget-coverArt > .cover-art,
+[data-testid="cover-art-button"] .cover-art,
#fad-art {
animation: rotate-cover_img 24s linear infinite paused;
}
.turntable-spinning .main-nowPlayingWidget-coverArt > .cover-art,
+.turntable-spinning [data-testid="cover-art-button"] .cover-art,
.turntable-spinning #fad-art {
animation-play-state: running;
}
diff --git a/themes/turntable/metadata.json b/themes/turntable/metadata.json
index 0e391377..9f8cf320 100644
--- a/themes/turntable/metadata.json
+++ b/themes/turntable/metadata.json
@@ -1,7 +1,7 @@
{
"name": "turntable",
"kind": "theme",
- "version": "0.1.0",
+ "version": "0.1.1",
"authors": ["Grason Chan"],
"description": "Vinyl deck look whose round cover art spins while a track plays",
"entries": {