Skip to content

Commit 20d7200

Browse files
WaterrrForeverclaudee-jung
authored
feat(skills): add music-to-video, a beat-synced music-driven video workflow (#1665)
* feat(skills): add bgm-to-video skill Add the music-to-video skill: turns a music/BGM track into a kinetic typography video. Includes the director/builder/music-reader/finalize agents, reference contracts, beatgrid analysis script, motion-primitive library, and starter templates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): catch CSS↔GSAP transform conflicts in scoped selectors and frame sub-compositions gsap_css_transform_conflict existed but missed the most common real-world shape (a label centered with CSS translateX(-50%) plus a GSAP xPercent that stacks to -100% in the capture path), for three independent reasons: - selector matching was exact-string, so a scoped/grouped GSAP selector ("#root .label, #root .sub") never matched a CSS class rule (.label) - the acorn parser only captures timeline-rooted calls (tl.to/tl.set), so a standalone gsap.set("#root .label", { xPercent: -50 }) was invisible to it - lintProject read compositions/ non-recursively, so per-frame compositions in compositions/frames/*.html were never linted at all Fix: token-decompose grouped/descendant/compound selectors and match by id/class against CSS transform rules; additionally scan standalone gsap.* transform calls; and recurse into compositions/ subdirectories so frame sub-compositions are linted. Adds unit tests (grouped gsap.set repro, descendant tl.to, negative case) and an end-to-end lintProject test that writes compositions/frames/04-*.html and asserts the conflict is reported there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): add beat-synced montage authoring recipe * feat(skills): unify bgm-to-video flows into music-to-video Replace bgm-to-video, bgm-to-video-new, bgm-to-video-refactor, and the standalone beat-sync/montage skills with a single music-to-video skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): register music-to-video in the hyperframes router Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): add music-source brief to music-to-video Step 0 Check for user-supplied audio first; otherwise guide BGM generation via /hyperframes-media. Note the skill targets fast, high-energy BGM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(producer): restore css-var-fonts regression baseline Accidentally deleted by a prior `git add -A`; it is the golden output.mp4 the distributed regression harness diffs against. Restored byte-identical to main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(skills): apply oxfmt to music-to-video and router docs Fixes the Format / Preflight CI checks on the new skill files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(producer): store css-var-fonts baseline as raw binary, not LFS pointer The previous restore was re-filtered into a 130-byte LFS pointer by the .gitattributes lfs rule; main stores this fixture as a raw binary blob committed directly. Commit the exact blob so the regression harness reads real frames and the file matches main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(lint): keep the fallow audit gate green Extract rootClassStyledSelectors so the subcomposition_root_styled_by_class rule drops below the complexity threshold, and ignore the music-to-video reference HTML (template + motion-primitive materials forked by path, not import-graph reachable) — same treatment as motion-graphics/grounding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: unblock music video ci checks * docs: refine music-to-video planning catalogs --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: e-jung <8334081+e-jung@users.noreply.github.com>
2 parents 08328b0 + a23ca34 commit 20d7200

143 files changed

Lines changed: 11807 additions & 47 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@
5959
// prose), not import-graph reachable.
6060
"skills/motion-graphics/grounding/**",
6161
"skills/motion-graphics/categories/**",
62+
// Agent-invoked reference materials (template + motion-primitive HTML, catalogs),
63+
// forked by path by the frame-worker per SKILL.md prose, not import-graph reachable.
64+
"skills/music-to-video/references/**",
6265
// Bundled @font-face data (read at runtime via fs.readFileSync, invisible
6366
// to the import graph) + its manual rebuild tool.
6467
"skills/**/fonts/**",

packages/cli/src/utils/lintProject.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,47 @@ describe("lintProject", () => {
8282
expect(mediaFinding).toBeDefined();
8383
});
8484

85+
it("recurses into compositions/frames/ and flags a CSS↔GSAP transform conflict there", async () => {
86+
// End-to-end guard: a per-frame composition under compositions/frames/ that
87+
// seats centering via a standalone gsap.set on a grouped #root-scoped selector
88+
// against a CSS class transform — the exact shape that shipped off-centre.
89+
// Both the recursive discovery and the strengthened rule must fire.
90+
const dir = tmpProject("lint-frames");
91+
dirs.push(dir);
92+
writeFileSync(join(dir, "index.html"), validHtml());
93+
const framesDir = join(dir, "compositions", "frames");
94+
mkdirSync(framesDir, { recursive: true });
95+
const frameHtml = `<template data-composition-id="04-mechanism">
96+
<div id="m04-root" data-width="1920" data-height="1080">
97+
<div class="m04-label">edit op</div>
98+
</div>
99+
<style> .m04-label { position: absolute; left: 960px; transform: translateX(-50%); } </style>
100+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
101+
<script>
102+
window.__timelines = window.__timelines || {};
103+
const tl = gsap.timeline({ paused: true });
104+
gsap.set("#m04-root .m04-label", { xPercent: -50 });
105+
tl.to(".m04-label", { y: 0, opacity: 1, duration: 0.4 }, 0.5);
106+
window.__timelines["04-mechanism"] = tl;
107+
</script>
108+
</template>`;
109+
writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml);
110+
111+
const project: ProjectDir = {
112+
dir,
113+
name: "test-project",
114+
indexPath: join(dir, "index.html"),
115+
};
116+
const { results } = await lintProject(project);
117+
118+
const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html");
119+
expect(frameResult).toBeDefined();
120+
const conflict = frameResult?.result.findings.find(
121+
(f) => f.code === "gsap_css_transform_conflict",
122+
);
123+
expect(conflict).toBeDefined();
124+
});
125+
85126
it("lints sub-compositions in compositions/ directory", async () => {
86127
const project = makeProject(validHtml(), {
87128
"captions.html": htmlWithMissingMediaId(),

packages/cli/src/utils/lintProject.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,19 @@ export async function lintProject(project: ProjectDir): Promise<ProjectLintResul
200200
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
201201
const compositionsDir = resolve(project.dir, "compositions");
202202
if (existsSync(compositionsDir)) {
203-
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
203+
// Recurse: per-frame compositions live in nested dirs (e.g. compositions/frames/*.html).
204+
// A non-recursive readdir silently skipped them, so sub-composition rules never ran on
205+
// the frames that make up the video. Walk the whole tree; keep posix-style src paths.
206+
const collectHtmlFiles = (dir: string, rel: string): string[] => {
207+
const out: string[] = [];
208+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
209+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
210+
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
211+
else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath);
212+
}
213+
return out;
214+
};
215+
const files = collectHtmlFiles(compositionsDir, "").sort();
204216
for (const file of files) {
205217
const filePath = join(compositionsDir, file);
206218
const html = readFileSync(filePath, "utf-8");

packages/core/src/lint/rules/composition.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { LintContext, HyperframeLintFinding } from "../context";
1+
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
22
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
33
import { COMPOSITION_VARIABLE_TYPES } from "../../core.types";
44

@@ -37,6 +37,45 @@ function isCompositionRootOrMount(rawTag: string): boolean {
3737
);
3838
}
3939

40+
// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
41+
// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
42+
// full CSS parser, and rules elsewhere in this file scan CSS the same way.
43+
function extractCssSelectors(css: string): string[] {
44+
const out: string[] = [];
45+
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
46+
const ruleHeader = /([^{}]+)\{/g;
47+
let m: RegExpExecArray | null;
48+
while ((m = ruleHeader.exec(noComments)) !== null) {
49+
const header = (m[1] ?? "").trim();
50+
if (!header || header.startsWith("@")) continue;
51+
for (const sel of header.split(",")) {
52+
const s = sel.trim();
53+
if (s) out.push(s);
54+
}
55+
}
56+
return out;
57+
}
58+
59+
// Class tokens in a selector's leftmost compound (before the first descendant /
60+
// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"].
61+
function leftmostCompoundClasses(selector: string): string[] {
62+
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
63+
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
64+
}
65+
66+
// Distinct selectors across all <style> blocks whose leftmost compound keys off one
67+
// of the root element's own classes — the ones that break under id-scoping.
68+
function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[]): string[] {
69+
const offenders: string[] = [];
70+
for (const style of styles) {
71+
for (const selector of extractCssSelectors(style.content)) {
72+
const hitsRoot = leftmostCompoundClasses(selector).some((c) => rootClasses.includes(c));
73+
if (hitsRoot && !offenders.includes(selector)) offenders.push(selector);
74+
}
75+
}
76+
return offenders;
77+
}
78+
4079
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
4180
// invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
4281
// Sub-compositions live in compositions/ but are served relative to the project
@@ -576,4 +615,44 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
576615
}
577616
return findings;
578617
},
618+
619+
// subcomposition_root_styled_by_class
620+
// A sub-composition's <style> is scoped at render time to
621+
// `[data-composition-id="<id>"] <selector>` so scenes inlined into one document
622+
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
623+
// element's own class (e.g. `.frame { ... }` on the same element that carries
624+
// data-composition-id) therefore becomes a DESCENDANT selector that can never
625+
// match the root — the whole scene renders unstyled (tiny text top-left, images
626+
// at natural size). lint/validate/inspect evaluate the file in isolation (no
627+
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
628+
// break is invisible until the composited MP4 render. Style the root via `#root`
629+
// (the scoper special-cases the root id) and descendants via plain selectors,
630+
// like the registry blocks — the runtime already scopes each scene by id, so a
631+
// class namespace on the root is redundant.
632+
({ rootTag, rootCompositionId, styles, options }) => {
633+
if (!options.isSubComposition) return [];
634+
if (isRegistrySourceFile(options.filePath)) return [];
635+
if (!rootTag || !rootCompositionId) return [];
636+
637+
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
638+
if (rootClasses.length === 0) return [];
639+
640+
const offenders = rootClassStyledSelectors(styles, rootClasses);
641+
if (offenders.length === 0) return [];
642+
643+
const example = offenders.slice(0, 3).join(", ");
644+
return [
645+
{
646+
code: "subcomposition_root_styled_by_class",
647+
severity: "error",
648+
message:
649+
`Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). ` +
650+
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root — the scene renders unstyled (tiny text top-left, full-size images). ` +
651+
`lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
652+
selector: example,
653+
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
654+
snippet: truncateSnippet(rootTag.raw),
655+
},
656+
];
657+
},
579658
];

packages/core/src/lint/rules/gsap.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,77 @@ describe("GSAP rules", () => {
519519
expect(conflicts.length).toBeGreaterThanOrEqual(1);
520520
});
521521

522+
it("detects conflict via a SCOPED descendant selector (tl.to)", async () => {
523+
const html = `
524+
<html><body>
525+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
526+
<div class="lab">Label</div>
527+
</div>
528+
<style>
529+
.lab { transform: translateX(-50%); }
530+
</style>
531+
<script>
532+
window.__timelines = window.__timelines || {};
533+
const tl = gsap.timeline({ paused: true });
534+
tl.to("#root .lab", { x: 40, opacity: 1, duration: 0.4 }, 0.5);
535+
window.__timelines["c1"] = tl;
536+
</script>
537+
</body></html>`;
538+
const result = await lintHyperframeHtml(html);
539+
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
540+
expect(finding).toBeDefined();
541+
expect(finding?.selector).toBe("#root .lab");
542+
});
543+
544+
it("detects conflict via a standalone gsap.set with a GROUPED scoped selector", async () => {
545+
// The exact shape that slipped through: centering seated with a standalone
546+
// gsap.set on a grouped, #root-scoped selector, against a CSS class transform.
547+
const html = `
548+
<html><body>
549+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
550+
<div class="lab">A</div><div class="sub">B</div>
551+
</div>
552+
<style>
553+
.lab { transform: translateX(-50%); }
554+
.sub { transform: translateX(-50%); }
555+
</style>
556+
<script>
557+
window.__timelines = window.__timelines || {};
558+
const tl = gsap.timeline({ paused: true });
559+
gsap.set("#root .lab, #root .sub", { xPercent: -50 });
560+
tl.to(".lab", { y: 0, opacity: 1, duration: 0.4 }, 0.5);
561+
window.__timelines["c1"] = tl;
562+
</script>
563+
</body></html>`;
564+
const result = await lintHyperframeHtml(html);
565+
const finding = result.findings.find(
566+
(f) => f.code === "gsap_css_transform_conflict" && f.selector === "#root .lab, #root .sub",
567+
);
568+
expect(finding).toBeDefined();
569+
expect(finding?.severity).toBe("error");
570+
});
571+
572+
it("does NOT false-positive when a scoped selector targets a class WITHOUT a CSS transform", async () => {
573+
const html = `
574+
<html><body>
575+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
576+
<div class="lab">Label</div>
577+
</div>
578+
<style>
579+
.lab { opacity: 0; }
580+
</style>
581+
<script>
582+
window.__timelines = window.__timelines || {};
583+
const tl = gsap.timeline({ paused: true });
584+
tl.to("#root .lab", { x: 40, opacity: 1, duration: 0.4 }, 0.5);
585+
window.__timelines["c1"] = tl;
586+
</script>
587+
</body></html>`;
588+
const result = await lintHyperframeHtml(html);
589+
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
590+
expect(conflict).toBeUndefined();
591+
});
592+
522593
it("reports error when GSAP is used without a GSAP script tag", async () => {
523594
const html = `
524595
<html><body>

packages/core/src/lint/rules/gsap.ts

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,78 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
321321
return parts.length > 0 ? parts.join(", ") : null;
322322
}
323323

324+
// ── CSS-transform ↔ GSAP-transform conflict matching ─────────────────────────
325+
326+
// Transform components that COMBINE with a CSS translate/scale on the same
327+
// element. GSAP bakes the element's existing CSS transform in when it seeks, so
328+
// these stack rather than override in the capture path (e.g. CSS translateX(-50%)
329+
// + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps
330+
// to CSS rotate(), which this rule treats separately (no false positive on spin).
331+
const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
332+
const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];
333+
334+
type GsapTransformCall = {
335+
method: string;
336+
selector: string;
337+
properties: string[];
338+
raw: string;
339+
};
340+
341+
// Decompose a (possibly grouped / descendant / compound) GSAP target selector
342+
// into the simple `#id` / `.class` tokens of the elements it actually targets —
343+
// the RIGHTMOST compound of each comma group is the targeted element. This lets a
344+
// CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector
345+
// (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup
346+
// missed — so every scoped/grouped selector slipped past the rule entirely.
347+
function targetedSelectorTokens(selector: string): Set<string> {
348+
const tokens = new Set<string>();
349+
for (const group of selector.split(",")) {
350+
const compounds = group
351+
.trim()
352+
.split(/[\s>+~]+/)
353+
.filter(Boolean);
354+
const last = compounds[compounds.length - 1];
355+
if (!last) continue;
356+
const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
357+
if (simple) for (const token of simple) tokens.add(token);
358+
}
359+
return tokens;
360+
}
361+
362+
// Find a CSS transform conflicting with a GSAP target selector: exact-string
363+
// match first (fast path + back-compat with the original behaviour), then a
364+
// token match so scoped/grouped/descendant selectors resolve to their class/id.
365+
function matchCssTransform(gsapSelector: string, cssMap: Map<string, string>): string | undefined {
366+
if (cssMap.size === 0) return undefined;
367+
const direct = cssMap.get(gsapSelector);
368+
if (direct) return direct;
369+
const tokens = targetedSelectorTokens(gsapSelector);
370+
for (const [cssSelector, value] of cssMap) {
371+
if (tokens.has(cssSelector)) return value;
372+
}
373+
return undefined;
374+
}
375+
376+
// Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls.
377+
// The acorn timeline parser only captures calls rooted on the timeline var
378+
// (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })`
379+
// — a common way to seat shared base transforms before the timeline runs — is
380+
// invisible to it, so the conflict rule never saw it. Variable selectors
381+
// (`gsap.set(kicker, …)`) can't be resolved statically and are skipped.
382+
function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[] {
383+
const calls: GsapTransformCall[] = [];
384+
const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
385+
let match: RegExpExecArray | null;
386+
while ((match = pattern.exec(script)) !== null) {
387+
const method = match[1] ?? "set";
388+
const selector = match[3] ?? "";
389+
const propsBody = match[4] ?? "";
390+
const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
391+
calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
392+
}
393+
return calls;
394+
}
395+
324396
// ── GSAP rules ─────────────────────────────────────────────────────────────
325397

326398
// fallow-ignore-next-line complexity
@@ -505,27 +577,40 @@ export const gsapRules: LintRule<LintContext>[] = [
505577
if (!/gsap\.timeline/.test(script.content)) continue;
506578
const windows = await cachedExtractGsapWindows(script.content);
507579

580+
// Two sources of transform-setting calls: timeline-rooted tweens (from the
581+
// acorn parser) and standalone gsap.* calls (regex — the parser ignores
582+
// these). Normalize both into one shape and run the same conflict check.
583+
const calls: GsapTransformCall[] = [
584+
...windows.map((win) => ({
585+
method: win.method,
586+
selector: win.targetSelector,
587+
properties: win.properties,
588+
raw: win.raw,
589+
})),
590+
...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
591+
];
592+
508593
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
509594
const conflicts = new Map<string, Conflict>();
510595

511-
for (const win of windows) {
596+
for (const call of calls) {
512597
// from() and fromTo() both supply explicit start values so GSAP owns
513598
// the full transform from t=0, making the CSS conflict moot
514-
if (win.method === "fromTo" || win.method === "from") continue;
515-
const sel = win.targetSelector;
516-
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
517-
const translateProps = win.properties.filter((p) =>
518-
["x", "y", "xPercent", "yPercent"].includes(p),
599+
if (call.method === "fromTo" || call.method === "from") continue;
600+
const sel = call.selector;
601+
const translateProps = call.properties.filter((p) =>
602+
CONFLICTING_TRANSLATE_PROPS.includes(p),
519603
);
520-
const scaleProps = win.properties.filter((p) => p === "scale");
604+
const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
521605
const cssFromTranslate =
522-
translateProps.length > 0 ? cssTranslateSelectors.get(cssKey) : undefined;
523-
const cssFromScale = scaleProps.length > 0 ? cssScaleSelectors.get(cssKey) : undefined;
606+
translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : undefined;
607+
const cssFromScale =
608+
scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : undefined;
524609
if (!cssFromTranslate && !cssFromScale) continue;
525610
const existing = conflicts.get(sel) ?? {
526611
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
527612
props: new Set<string>(),
528-
raw: win.raw,
613+
raw: call.raw,
529614
};
530615
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
531616
conflicts.set(sel, existing);

0 commit comments

Comments
 (0)