feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379)

This commit is contained in:
Vance Ingalls
2026-06-15 00:46:17 -07:00
committed by GitHub
parent 8b56e558c6
commit 6dcbb5530e
17 changed files with 1442 additions and 61 deletions
+16
View File
@@ -83,6 +83,14 @@
"import": "./src/parsers/gsapParser.ts",
"types": "./src/parsers/gsapParser.ts"
},
"./gsap-parser-acorn": {
"import": "./src/parsers/gsapParserAcorn.ts",
"types": "./src/parsers/gsapParserAcorn.ts"
},
"./gsap-writer-acorn": {
"import": "./src/parsers/gsapWriterAcorn.ts",
"types": "./src/parsers/gsapWriterAcorn.ts"
},
"./gsap-constants": {
"import": "./src/parsers/gsapConstants.ts",
"types": "./src/parsers/gsapConstants.ts"
@@ -166,6 +174,14 @@
"import": "./dist/parsers/gsapParser.js",
"types": "./dist/parsers/gsapParser.d.ts"
},
"./gsap-parser-acorn": {
"import": "./dist/parsers/gsapParserAcorn.js",
"types": "./dist/parsers/gsapParserAcorn.d.ts"
},
"./gsap-writer-acorn": {
"import": "./dist/parsers/gsapWriterAcorn.js",
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
},
"./gsap-constants": {
"import": "./dist/parsers/gsapConstants.js",
"types": "./dist/parsers/gsapConstants.d.ts"
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6b — acorn vs golden differential harness.
*
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live
@@ -912,3 +912,140 @@ describe("native GSAP keyframes parsing", () => {
expect(Object.keys(anim.properties)).toHaveLength(0);
});
});
// ── motionPath parsing ────────────────────────────────────────────────────────
describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});
it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });
expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});
it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});
it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);
expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});
it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});
it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP read path — acorn + acorn-walk.
*
@@ -1046,6 +1046,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
export interface ParsedGsapAcornForWrite {
ast: any;
timelineVar: string;
hasTimeline: boolean;
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}
@@ -1075,7 +1076,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
call,
animation: animations[i]!,
}));
return { ast, timelineVar, located };
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
} catch {
return null;
}
+2 -1
View File
@@ -91,6 +91,7 @@ export function serializeGsapAnimations(
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
return aNum - bNum;
});
// fallow-ignore-next-line complexity
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
@@ -200,7 +201,6 @@ export function getAnimationsForElementId(
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
@@ -247,6 +247,7 @@ export function keyframesToGsapAnimations(
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
// fallow-ignore-next-line complexity
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6c — acorn write path with magic-string offset-splice.
*
+93 -17
View File
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP write path magic-string offset-splice.
*
@@ -8,15 +8,20 @@
*/
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js";
import {
parseGsapScriptAcornForWrite,
type ParsedGsapAcornForWrite,
type TweenCallInfo,
} from "./gsapParserAcorn.js";
import * as acornWalk from "acorn-walk";
// ── Code generation helpers ──────────────────────────────────────────────────
function valueToCode(value: number | string): string {
function valueToCode(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function safeKey(key: string): string {
@@ -32,7 +37,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
@@ -121,7 +126,7 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void
* Update a property value if it exists, or append a new key: val before the
* closing `}`. Call with the full ObjectExpression node.
*/
function upsertProp(ms: MagicString, objNode: any, key: string, value: number | string): void {
function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void {
if (objNode?.type !== "ObjectExpression") return;
const existing = findPropertyNode(objNode, key);
if (existing) {
@@ -132,6 +137,31 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: number |
}
}
// ── Insertion helpers ─────────────────────────────────────────────────────────
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
function isTimelineRooted(node: any, timelineVar: string): boolean {
if (node?.type === "Identifier") return node.name === timelineVar;
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
return false;
}
/**
* Find the byte offset after which to insert a new statement (tween or label).
* Returns null when no timeline declaration exists in the script callers must
* not emit `tl.xxx()` calls in that case as `tl` would be undefined at render.
*/
function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
if (parsed.located.length > 0) {
const lastCall = parsed.located[parsed.located.length - 1]!.call;
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
return exprStmt?.end ?? lastCall.node.end;
}
if (!parsed.hasTimeline) return null;
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
return tlDecl?.end ?? (parsed.ast.end as number);
}
// ── Public write API ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -179,6 +209,12 @@ export function updateAnimationInScript(
}
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
upsertProp(ms, call.varsArg, key, value);
}
}
return ms.toString();
}
@@ -189,19 +225,11 @@ export function addAnimationToScript(
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };
const ms = new MagicString(script);
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
let insertionPoint: number;
if (parsed.located.length > 0) {
const lastCall = parsed.located[parsed.located.length - 1]!.call;
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
insertionPoint = exprStmt?.end ?? lastCall.node.end;
} else {
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
insertionPoint = tlDecl?.end ?? script.length;
}
ms.appendLeft(insertionPoint, "\n" + statementCode);
const result = ms.toString();
@@ -366,3 +394,51 @@ export function removeKeyframeFromScript(
removeProp(ms, match.prop, allProps);
return ms.toString();
}
// ── Label write ops ───────────────────────────────────────────────────────────
export function addLabelToScript(script: string, name: string, position: number): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return script;
const ms = new MagicString(script);
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
ms.appendLeft(insertionPoint, "\n" + labelCode);
return ms.toString();
}
export function removeLabelFromScript(script: string, name: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const targets: any[] = [];
acornWalk.simple(parsed.ast, {
// fallow-ignore-next-line complexity
ExpressionStatement(node: any) {
const expr = node.expression;
if (
expr?.type === "CallExpression" &&
expr.callee?.type === "MemberExpression" &&
isTimelineRooted(expr.callee.object, parsed.timelineVar) &&
expr.callee.property?.name === "addLabel" &&
expr.arguments?.[0]?.type === "Literal" &&
expr.arguments[0].value === name
) {
targets.push(node);
}
},
});
if (!targets.length) return script;
const ms = new MagicString(script);
for (const target of targets) {
const end =
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
ms.remove(target.start, end);
}
return ms.toString();
}
+39 -2
View File
@@ -10,18 +10,36 @@
import type { JsonPatchOp, OverrideSet } from "../types.js";
import type { ParsedDocument } from "./model.js";
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
import {
findById,
findRoot,
setElementStyles,
setOwnText,
setGsapScript,
setStyleSheet,
} from "./model.js";
import { keyToPath } from "./patches.js";
// ─── Path parser ────────────────────────────────────────────────────────────
interface ParsedPath {
type: "style" | "text" | "attribute" | "timing" | "hold" | "element" | "variable" | "metadata";
type:
| "style"
| "text"
| "attribute"
| "timing"
| "hold"
| "element"
| "variable"
| "metadata"
| "script"
| "stylesheet";
id?: string;
prop?: string;
field?: string;
}
// fallow-ignore-next-line complexity
function parsePath(path: string): ParsedPath | null {
const styleM = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleM) return { type: "style", id: styleM[1], prop: styleM[2] };
@@ -52,6 +70,9 @@ function parsePath(path: string): ParsedPath | null {
const metaM = /^\/metadata\/(.+)$/.exec(path);
if (metaM) return { type: "metadata", field: metaM[1] };
if (path === "/script/gsap") return { type: "script" };
if (path === "/style/css") return { type: "stylesheet" };
return null;
}
@@ -185,6 +206,22 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
break;
}
case "script": {
if (patch.op !== "remove") {
setGsapScript(parsed.document, String(patch.value ?? ""));
}
break;
}
case "stylesheet": {
if (patch.op === "remove") {
setStyleSheet(parsed.document, "");
} else {
setStyleSheet(parsed.document, String(patch.value ?? ""));
}
break;
}
case "metadata": {
const root = findRoot(parsed.document);
if (!root || !p.field) return;
+137
View File
@@ -0,0 +1,137 @@
/**
* Hand-rolled flat-CSS rule editor.
*
* Handles only simple flat rules (`selector { declarations }`) no nesting,
* no @-rules, no CSS comments. Sufficient for composition <style> blocks
* generated by hyperframes, which never contain those constructs.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
interface CssRule {
selector: string;
body: string;
/** Byte offset of the first char of the selector. */
start: number;
/** Byte offset after the closing `}`. */
end: number;
}
// ─── Parsing ──────────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function parseCssRules(css: string): CssRule[] {
const rules: CssRule[] = [];
let i = 0;
const len = css.length;
while (i < len) {
const braceOpen = css.indexOf("{", i);
if (braceOpen === -1) break;
const selector = css.slice(i, braceOpen).trim();
if (!selector) {
i = braceOpen + 1;
continue;
}
// skip leading whitespace to get the true selector start
let selStart = i;
while (selStart < braceOpen && " \t\r\n".includes(css[selStart]!)) selStart++;
// find closing }, respecting quoted strings so `content: "}"` doesn't end the rule
let j = braceOpen + 1;
let quote: string | null = null;
while (j < len) {
const ch = css[j]!;
if (quote) {
if (ch === "\\" && j + 1 < len)
j++; // skip escaped char
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === "}") {
break;
}
j++;
}
rules.push({ selector, body: css.slice(braceOpen + 1, j), start: selStart, end: j + 1 });
i = j + 1;
}
return rules;
}
// fallow-ignore-next-line complexity
function parseDeclarations(body: string): Record<string, string> {
const decls: Record<string, string> = {};
let depth = 0;
let start = 0;
for (let i = 0; i <= body.length; i++) {
const ch = i < body.length ? body[i]! : ";"; // sentinel flush
if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === ";" && depth === 0) {
const part = body.slice(start, i);
const colon = part.indexOf(":");
if (colon !== -1) {
const prop = part.slice(0, colon).trim();
const value = part.slice(colon + 1).trim();
if (prop && value) decls[prop] = value;
}
start = i + 1;
}
}
return decls;
}
function serializeDeclarations(decls: Record<string, string>): string {
const entries = Object.entries(decls);
if (!entries.length) return "";
return " " + entries.map(([k, v]) => `${k}: ${v}`).join("; ") + "; ";
}
function normalizeSelector(sel: string): string {
return sel.trim().replace(/\s+/g, " ");
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Update or insert a CSS rule.
*
* Finds the first rule whose selector matches (after whitespace normalization)
* and merges the given declarations into it. A null value removes the property.
* If no matching rule exists, appends a new one.
*
* Returns the modified CSS string. Returns `css` unchanged when nothing changed.
*/
export function upsertCssRule(
css: string,
selector: string,
styles: Record<string, string | null>,
): string {
const normalized = normalizeSelector(selector);
const rules = parseCssRules(css);
const idx = rules.findIndex((r) => normalizeSelector(r.selector) === normalized);
if (idx !== -1) {
const rule = rules[idx]!;
const decls = parseDeclarations(rule.body);
for (const [prop, value] of Object.entries(styles)) {
if (value === null) {
delete decls[prop];
} else {
decls[prop] = value;
}
}
const newRuleText = `${rule.selector} {${serializeDeclarations(decls)}}`;
return css.slice(0, rule.start) + newRuleText + css.slice(rule.end);
}
// Append new rule — skip if all values are null (nothing to write).
const newDecls: Record<string, string> = {};
for (const [prop, value] of Object.entries(styles)) {
if (value !== null) newDecls[prop] = value;
}
if (!Object.keys(newDecls).length) return css;
const newRuleText = `${selector} {${serializeDeclarations(newDecls)}}`;
const sep = css.length > 0 && !css.endsWith("\n") ? "\n" : "";
return css + sep + newRuleText + "\n";
}
+56
View File
@@ -132,6 +132,62 @@ export function setOwnText(el: Element, text: string): void {
}
}
// ─── CSS style helpers ────────────────────────────────────────────────────────
function findStyleElement(document: Document): Element | null {
return document.querySelector("style") as unknown as Element | null;
}
export function getStyleSheet(document: Document): string {
return findStyleElement(document)?.textContent ?? "";
}
export function setStyleSheet(document: Document, css: string): void {
const existing = findStyleElement(document);
if (!css) {
existing?.remove();
return;
}
let el = existing;
if (!el) {
el = document.createElement("style") as unknown as Element;
const head =
(document.querySelector("head") as unknown as Element | null) ??
(document.body as unknown as Element);
(head as any).appendChild(el);
}
el.textContent = css;
}
// ─── GSAP script helpers ──────────────────────────────────────────────────────
function findGsapScriptElement(document: Document): Element | null {
const scripts = document.querySelectorAll("script");
for (const script of Array.from(scripts)) {
const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger"))
return script as unknown as Element;
}
return null;
}
export function getGsapScript(document: Document): string | null {
const el = findGsapScriptElement(document);
return el ? (el.textContent ?? "") : null;
}
export function setGsapScript(document: Document, newScript: string): void {
let el = findGsapScriptElement(document);
if (!el) {
el = document.createElement("script") as unknown as Element;
const head =
(document.querySelector("head") as unknown as Element | null) ??
(document.body as unknown as Element);
(head as any).appendChild(el);
}
el.textContent = newScript;
}
// ─── Sibling index ────────────────────────────────────────────────────────────
export function getSiblingIndex(el: Element): number {
@@ -0,0 +1,217 @@
/**
* setClassStyle handler tests flat CSS rule upsert via <style> element.
*/
import { describe, it, expect } from "vitest";
import { parseMutable } from "./model.js";
import { applyOp, validateOp } from "./mutate.js";
import { applyPatchesToDocument } from "./apply-patches.js";
import { serializeDocument } from "./serialize.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const CSS = `.box { opacity: 0; transform: translateX(-50px); }
.title { color: #fff; font-size: 64px; }
`;
function makeHtml(style = CSS) {
return `<!DOCTYPE html><html><head><style>${style}</style></head><body>
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" class="box"></div>
<h1 data-hf-id="hf-title" class="title">Hello</h1>
</div></body></html>`.trim();
}
function fresh(style = CSS) {
return parseMutable(makeHtml(style));
}
function getStyleText(parsed: ReturnType<typeof parseMutable>): string {
const doc = serializeDocument(parsed);
const m = /<style>([\s\S]*?)<\/style>/i.exec(doc);
return m ? m[1]! : "";
}
// ─── validateOp ───────────────────────────────────────────────────────────────
describe("validateOp setClassStyle", () => {
it("returns true (always valid — creates <style> if absent)", () => {
expect(
validateOp(fresh(), { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
).toBe(true);
});
it("returns true even when no <style> element present", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
expect(
validateOp(noStyle, { type: "setClassStyle", selector: ".box", styles: { opacity: "1" } }),
).toBe(true);
});
});
// ─── setClassStyle: update existing rule ──────────────────────────────────────
describe("setClassStyle — update existing rule", () => {
it("adds a new property to an existing rule", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { color: "red" },
});
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.path).toBe("/style/css");
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("color: red");
expect(newCss).toContain("opacity: 0");
});
it("overwrites an existing property value", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("opacity: 1");
expect(newCss).not.toContain("opacity: 0");
expect(newCss).toContain("translateX(-50px)");
});
it("removes a property when value is null", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: null },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).not.toContain("opacity");
expect(newCss).toContain("translateX(-50px)");
});
it("leaves other rules untouched", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".title");
expect(newCss).toContain("color: #fff");
});
});
// ─── setClassStyle: insert new rule ──────────────────────────────────────────
describe("setClassStyle — insert new rule", () => {
it("appends a new rule when selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".new",
styles: { display: "flex", gap: "8px" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".new");
expect(newCss).toContain("display: flex");
expect(newCss).toContain("gap: 8px");
expect(newCss).toContain(".box");
});
it("creates <style> element when none exists", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(result.forward).toHaveLength(1);
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain(".box");
expect(newCss).toContain("opacity: 1");
});
});
// ─── no-op cases ─────────────────────────────────────────────────────────────
describe("setClassStyle — no-ops", () => {
it("returns EMPTY when all values are null and selector not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".nonexistent",
styles: { opacity: null },
});
expect(result.forward).toHaveLength(0);
});
});
// ─── inverse restores original ────────────────────────────────────────────────
describe("setClassStyle — inverse patches", () => {
it("inverse restores original CSS", () => {
const parsed = fresh();
const original = getStyleText(parsed);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1", color: "blue" },
});
applyPatchesToDocument(parsed, result.inverse);
expect(getStyleText(parsed)).toBe(original);
});
it("undo on style-less composition does not create spurious <style> element", () => {
const noStyle = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noStyle, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
applyPatchesToDocument(noStyle, result.inverse);
const html = serializeDocument(noStyle);
expect(html).not.toContain("<style");
});
});
// ─── semicolon-containing CSS values ─────────────────────────────────────────
describe("setClassStyle — CSS values with semicolons (data URIs)", () => {
it("preserves data URI value when updating another property in same rule", () => {
const dataUriCss = ".hero { background: url(data:image/png;base64,abc=); color: red; }\n";
const parsed = fresh(dataUriCss);
const result = applyOp(parsed, {
type: "setClassStyle",
selector: ".hero",
styles: { color: "blue" },
});
const newCss = String(result.forward[0]?.value ?? "");
expect(newCss).toContain("url(data:image/png;base64,abc=)");
expect(newCss).toContain("color: blue");
expect(newCss).not.toContain("color: red");
});
});
// ─── DOM side-effect ─────────────────────────────────────────────────────────
describe("setClassStyle — DOM mutation", () => {
it("mutates the live <style> element in the document", () => {
const parsed = fresh();
applyOp(parsed, {
type: "setClassStyle",
selector: ".box",
styles: { opacity: "1" },
});
expect(getStyleText(parsed)).toContain("opacity: 1");
expect(getStyleText(parsed)).not.toContain("opacity: 0");
});
});
+422
View File
@@ -0,0 +1,422 @@
/**
* Phase 3b GSAP mutation handler tests.
*
* Verifies the 8 parser-backed ops: addGsapTween, setGsapTween, removeGsapTween,
* setGsapKeyframe, addGsapKeyframe, removeGsapKeyframe, addLabel, removeLabel.
*/
import { describe, it, expect } from "vitest";
import { parseMutable } from "./model.js";
import { applyOp, validateOp } from "./mutate.js";
import { applyPatchesToDocument } from "./apply-patches.js";
import { serializeDocument } from "./serialize.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const GSAP_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5, ease: "power2.out" }, 0.2);
window.__timelines["t"] = tl;`;
const KF_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
function makeHtml(script: string) {
return `<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
<div data-hf-id="hf-box" style="opacity: 0"></div>
<script>${script}</script>
</div>`.trim();
}
function fresh(script = GSAP_SCRIPT) {
return parseMutable(makeHtml(script));
}
function getScript(parsed: ReturnType<typeof parseMutable>): string {
const doc = serializeDocument(parsed);
const m = /<script>([\s\S]*?)<\/script>/i.exec(doc);
return m ? m[1]!.trim() : "";
}
// ─── validateOp gating on timeline existence ──────────────────────────────────
const NO_TIMELINE_SCRIPT = `gsap.defaults({ ease: "power1.out" });
window.__timelines = {};`;
describe("validateOp — no gsap.timeline() declaration", () => {
function freshNoTimeline() {
return parseMutable(makeHtml(NO_TIMELINE_SCRIPT));
}
it("addGsapTween → false when script has no timeline", () => {
expect(
validateOp(freshNoTimeline(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
}),
).toBe(false);
});
it("addLabel → false when script has no timeline", () => {
expect(validateOp(freshNoTimeline(), { type: "addLabel", name: "start", position: 0 })).toBe(
false,
);
});
it("addGsapTween dispatch returns EMPTY when no timeline — no dangling tl call emitted", () => {
const parsed = freshNoTimeline();
const scriptBefore = getScript(parsed);
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
});
expect(result.forward).toHaveLength(0);
expect(getScript(parsed)).toBe(scriptBefore);
});
});
// ─── validateOp returns true when GSAP script present ─────────────────────────
describe("validateOp with GSAP script", () => {
it("addGsapTween → true", () => {
expect(
validateOp(fresh(), {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
}),
).toBe(true);
});
it("removeGsapTween → true", () => {
expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "some-id" })).toBe(true);
});
it("addLabel → true", () => {
expect(validateOp(fresh(), { type: "addLabel", name: "start", position: 0 })).toBe(true);
});
});
// ─── addGsapTween ─────────────────────────────────────────────────────────────
describe("addGsapTween", () => {
it("inserts new tween and returns animationId in meta", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
});
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.path).toBe("/script/gsap");
expect(result.meta?.animationId).toBeTruthy();
expect(typeof result.meta?.animationId).toBe("string");
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("x: 100");
expect(newScript).toContain("duration: 0.3");
});
it("inverse patch restores original script", () => {
const parsed = fresh();
const original = getScript(parsed);
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", duration: 0.3, properties: { x: 100 } },
});
applyPatchesToDocument(parsed, result.inverse);
expect(getScript(parsed)).toBe(original);
});
it("adds repeat/yoyo as extras", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", duration: 1, properties: { y: 50 }, repeat: -1, yoyo: true },
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("repeat: -1");
expect(newScript).toContain("yoyo: true");
});
it("serializes stagger object as JSON, not [object Object]", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: {
method: "to",
duration: 1,
properties: { opacity: 1 },
stagger: { amount: 0.5, from: "center" } as any,
},
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain("[object Object]");
expect(newScript).toContain("amount");
});
it("adds fromTo tween with fromProperties and toProperties", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "addGsapTween",
target: "hf-box",
tween: {
method: "fromTo",
duration: 0.5,
fromProperties: { opacity: 0 },
toProperties: { opacity: 1 },
},
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("fromTo(");
expect(newScript).toContain("opacity: 0");
expect(newScript).toContain("opacity: 1");
});
it("returns EMPTY when no GSAP script", () => {
const noScript = parseMutable(
`<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box"></div></div>`,
);
const result = applyOp(noScript, {
type: "addGsapTween",
target: "hf-box",
tween: { method: "to", properties: { x: 1 } },
});
expect(result.forward).toHaveLength(0);
});
});
// ─── setGsapTween ─────────────────────────────────────────────────────────────
describe("setGsapTween", () => {
it("updates ease in existing tween", () => {
const parsed = fresh();
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
const result = applyOp(parsed, {
type: "setGsapTween",
animationId: animId,
properties: { ease: "power3.in" },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('"power3.in"');
expect(newScript).not.toContain('"power2.out"');
});
it("updates duration in existing tween", () => {
const parsed = fresh();
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
const result = applyOp(parsed, {
type: "setGsapTween",
animationId: animId,
properties: { duration: 1.5 },
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("duration: 1.5");
expect(newScript).not.toContain("duration: 0.5");
});
it("returns EMPTY for unknown animationId", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "setGsapTween",
animationId: "nonexistent-id",
properties: { ease: "power1.in" },
});
expect(result.forward).toHaveLength(0);
});
it("inverse restores original script", () => {
const parsed = fresh();
const original = getScript(parsed);
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
const result = applyOp(parsed, {
type: "setGsapTween",
animationId: animId,
properties: { ease: "power3.in" },
});
applyPatchesToDocument(parsed, result.inverse);
expect(getScript(parsed)).toBe(original);
});
});
// ─── removeGsapTween ──────────────────────────────────────────────────────────
describe("removeGsapTween", () => {
it("removes tween by animationId", () => {
const parsed = fresh();
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
const result = applyOp(parsed, { type: "removeGsapTween", animationId: animId });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain("opacity: 1");
});
it("returns EMPTY for unknown animationId", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "removeGsapTween", animationId: "no-such-id" });
expect(result.forward).toHaveLength(0);
});
it("inverse restores original script", () => {
const parsed = fresh();
const original = getScript(parsed);
const animId = `[data-hf-id="hf-box"]-to-200-visual`;
const result = applyOp(parsed, { type: "removeGsapTween", animationId: animId });
applyPatchesToDocument(parsed, result.inverse);
expect(getScript(parsed)).toBe(original);
});
});
// ─── Keyframe ops ─────────────────────────────────────────────────────────────
describe("addGsapKeyframe", () => {
it("inserts new keyframe at given percentage", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "addGsapKeyframe",
animationId: animId,
position: 25,
value: { opacity: 0.3 },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('"25%"');
expect(newScript).toContain("opacity: 0.3");
});
});
describe("setGsapKeyframe", () => {
it("updates keyframe value at index 1 (50%)", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "setGsapKeyframe",
animationId: animId,
keyframeIndex: 1,
value: { opacity: 0.5 },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("opacity: 0.5");
expect(newScript).not.toContain("opacity: 0.7");
});
it("returns EMPTY for out-of-range keyframeIndex", () => {
const parsed = fresh(KF_SCRIPT);
const result = applyOp(parsed, {
type: "setGsapKeyframe",
animationId: `[data-hf-id="hf-box"]-to-0-visual`,
keyframeIndex: 99,
value: { opacity: 0 },
});
expect(result.forward).toHaveLength(0);
});
it("position-only move preserves existing properties — does not delete keyframe", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "setGsapKeyframe",
animationId: animId,
keyframeIndex: 1,
position: 60,
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('"60%"');
expect(newScript).not.toContain('"50%"');
expect(newScript).toContain("opacity: 0.7");
});
it("ease-only update (same position, no value) does not corrupt keyframe", () => {
const kfWithEase = KF_SCRIPT.replace(
'"0%": { opacity: 0 }',
'"0%": { opacity: 0, ease: "power1.in" }',
);
const parsed = fresh(kfWithEase);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "setGsapKeyframe",
animationId: animId,
keyframeIndex: 0,
ease: "power2.out",
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('"0%"');
expect(newScript).toContain("opacity: 0");
});
});
describe("removeGsapKeyframe", () => {
it("removes keyframe at index 1 (50%)", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "removeGsapKeyframe",
animationId: animId,
keyframeIndex: 1,
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain('"50%"');
expect(newScript).toContain('"0%"');
expect(newScript).toContain('"100%"');
});
});
// ─── Label ops ────────────────────────────────────────────────────────────────
describe("addLabel", () => {
it("inserts addLabel call into script", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain('addLabel("intro"');
expect(newScript).toContain("0.5");
});
it("addLabel output is not blocked by GSAP validator", async () => {
const { validateCompositionGsap } = await import("@hyperframes/core/gsap-parser");
const parsed = fresh();
const result = applyOp(parsed, { type: "addLabel", name: "scene1", position: 1.0 });
const newScript = String(result.forward[0]?.value ?? "");
const { errors } = validateCompositionGsap(newScript);
const labelError = errors.find((e) => /addLabel/i.test(e));
expect(labelError).toBeUndefined();
});
it("inverse restores original script", () => {
const parsed = fresh();
const original = getScript(parsed);
const result = applyOp(parsed, { type: "addLabel", name: "intro", position: 0.5 });
applyPatchesToDocument(parsed, result.inverse);
expect(getScript(parsed)).toBe(original);
});
});
describe("removeLabel", () => {
it("removes addLabel call from script", () => {
const withLabel = GSAP_SCRIPT.replace(
'window.__timelines["t"] = tl;',
'tl.addLabel("intro", 0.5);\nwindow.__timelines["t"] = tl;',
);
const parsed = fresh(withLabel);
const result = applyOp(parsed, { type: "removeLabel", name: "intro" });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain("addLabel");
});
it("returns EMPTY when label not found", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "removeLabel", name: "nonexistent" });
expect(result.forward).toHaveLength(0);
});
});
+21 -13
View File
@@ -384,31 +384,39 @@ describe("validateOp", () => {
});
});
// ─── Phase 3b ops — fail loudly, feature-detectable ───────────────────────────
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
describe("Phase 3b ops", () => {
it("applyOp throws UnsupportedOpError instead of silently no-opping", () => {
expect(() =>
applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
id: "tw-1",
tween: { method: "from", fromProperties: { opacity: 0 } },
}),
).toThrowError(/Phase 3b/);
it("applyOp returns EMPTY when no GSAP script is present", () => {
const result = applyOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
tween: { method: "from", properties: { opacity: 0 } },
});
expect(result.forward).toHaveLength(0);
expect(result.inverse).toHaveLength(0);
});
it("validateOp returns false so can() feature-detects", () => {
it("validateOp returns false when no GSAP script present", () => {
expect(validateOp(fresh(), { type: "removeGsapTween", animationId: "tw-1" })).toBe(false);
expect(
validateOp(fresh(), {
type: "addGsapTween",
target: "hf-title",
id: "tw-1",
tween: { method: "from", fromProperties: { opacity: 0 } },
tween: { method: "from", properties: { opacity: 0 } },
}),
).toBe(false);
});
it("setClassStyle no longer throws — implemented in Phase 3b", () => {
expect(() =>
applyOp(fresh(), {
type: "setClassStyle",
selector: ".box",
styles: { color: "red" },
}),
).not.toThrow();
});
});
// ─── setCompositionMetadata — data-width/data-height forced override ─────────
+260 -12
View File
@@ -7,7 +7,7 @@
* Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers.
*/
import type { EditOp, HfId, JsonPatchOp } from "../types.js";
import type { EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
import type { ParsedDocument } from "./model.js";
import {
findById,
@@ -17,6 +17,10 @@ import {
getOwnText,
setOwnText,
getSiblingIndex,
getGsapScript,
setGsapScript,
getStyleSheet,
setStyleSheet,
} from "./model.js";
import {
stylePath,
@@ -27,15 +31,31 @@ import {
elementPath,
variablePath,
metaPath,
gsapScriptPath,
styleSheetPath,
scalarChange,
scalarDelete,
patchAdd,
patchRemove,
} from "./patches.js";
import { upsertCssRule } from "./cssWriter.js";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
addAnimationToScript,
updateAnimationInScript,
removeAnimationFromScript,
addKeyframeToScript,
removeKeyframeFromScript,
updateKeyframeInScript,
addLabelToScript,
removeLabelFromScript,
} from "@hyperframes/core/gsap-writer-acorn";
export interface MutationResult {
forward: JsonPatchOp[];
inverse: JsonPatchOp[];
meta?: { animationId?: string };
}
const EMPTY: MutationResult = { forward: [], inverse: [] };
@@ -137,19 +157,31 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleSetCompositionMetadata(parsed, op);
case "setVariableValue":
return handleSetVariableValue(parsed, op.id, op.value);
// Phase 3b parser-backed ops — fail loudly rather than silently no-op:
// a caller must never believe an animation edit succeeded when nothing
// was mutated and no patch was emitted.
case "setClassStyle":
case "addGsapTween":
return handleAddGsapTween(parsed, op.target, op.tween);
case "setGsapTween":
case "setGsapKeyframe":
case "addGsapKeyframe":
case "removeGsapKeyframe":
return handleSetGsapTween(parsed, op.animationId, op.properties);
case "removeGsapTween":
return handleRemoveGsapTween(parsed, op.animationId);
case "setGsapKeyframe":
return handleSetGsapKeyframe(
parsed,
op.animationId,
op.keyframeIndex,
op.position,
op.value,
op.ease,
);
case "addGsapKeyframe":
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
case "removeGsapKeyframe":
return handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
case "addLabel":
return handleAddLabel(parsed, op.name, op.position);
case "removeLabel":
throw new UnsupportedOpError(op.type);
return handleRemoveLabel(parsed, op.name);
case "setClassStyle":
return handleSetClassStyle(parsed, op.selector, op.styles);
}
}
@@ -424,9 +456,212 @@ function handleSetVariableValue(
return { forward: [p.forward], inverse: [p.inverse] };
}
// ─── setClassStyle handler ────────────────────────────────────────────────────
function handleSetClassStyle(
parsed: ParsedDocument,
selector: string,
styles: Record<string, string | null>,
): MutationResult {
const oldCss = getStyleSheet(parsed.document);
const newCss = upsertCssRule(oldCss, selector, styles);
if (newCss === oldCss) return EMPTY;
setStyleSheet(parsed.document, newCss);
const path = styleSheetPath();
return {
forward: [{ op: "replace", path, value: newCss }],
inverse: [oldCss === "" ? { op: "remove", path } : { op: "replace", path, value: oldCss }],
};
}
// ─── GSAP script patch helpers ───────────────────────────────────────────────
function gsapScriptChange(oldScript: string, newScript: string): MutationResult {
const path = gsapScriptPath();
return {
forward: [{ op: "replace", path, value: newScript }],
inverse: [{ op: "replace", path, value: oldScript }],
};
}
// ─── Phase 3b handlers ───────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function handleAddGsapTween(
parsed: ParsedDocument,
target: HfId,
tween: GsapTweenSpec,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const extras: Record<string, unknown> = {};
if (tween.repeat !== undefined) extras.repeat = tween.repeat;
if (tween.yoyo !== undefined) extras.yoyo = tween.yoyo;
if (tween.stagger !== undefined) extras.stagger = tween.stagger;
const toProps =
tween.method === "fromTo"
? ((tween.toProperties ?? {}) as Record<string, number | string>)
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
const animation: Omit<GsapAnimation, "id"> = {
targetSelector: `[data-hf-id="${target}"]`,
method: tween.method,
position: tween.position ?? 0,
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
...(tween.ease ? { ease: tween.ease } : {}),
properties: toProps,
...(tween.fromProperties
? { fromProperties: tween.fromProperties as Record<string, number | string> }
: {}),
...(Object.keys(extras).length > 0 ? { extras } : {}),
};
const { script: newScript, id: animationId } = addAnimationToScript(script, animation);
if (!animationId) return EMPTY;
setGsapScript(parsed.document, newScript);
return { ...gsapScriptChange(script, newScript), meta: { animationId } };
}
// fallow-ignore-next-line complexity
function handleSetGsapTween(
parsed: ParsedDocument,
animationId: string,
properties: Partial<GsapTweenSpec>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const updates: Partial<GsapAnimation> = {};
if (properties.duration !== undefined) updates.duration = properties.duration;
if (properties.ease !== undefined) updates.ease = properties.ease;
if (properties.position !== undefined) updates.position = properties.position;
const toProps = properties.toProperties ?? properties.properties;
if (toProps) updates.properties = toProps as Record<string, number | string>;
if (properties.fromProperties)
updates.fromProperties = properties.fromProperties as Record<string, number | string>;
const extras: Record<string, unknown> = {};
if (properties.repeat !== undefined) extras.repeat = properties.repeat;
if (properties.yoyo !== undefined) extras.yoyo = properties.yoyo;
if (Object.keys(extras).length > 0) updates.extras = extras;
const newScript = updateAnimationInScript(script, animationId, updates);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = removeAnimationFromScript(script, animationId);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
// fallow-ignore-next-line complexity
function handleSetGsapKeyframe(
parsed: ParsedDocument,
animationId: string,
keyframeIndex: number,
position: number | undefined,
value: Record<string, unknown> | undefined,
ease: string | undefined,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const parsedForWrite = parseGsapScriptAcornForWrite(script);
const located = parsedForWrite?.located.find((l) => l.id === animationId);
const kfs = located?.animation.keyframes?.keyframes;
if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return EMPTY;
const existingKf = kfs[keyframeIndex]!;
const currentPct = existingKf.percentage;
const targetPct = position ?? currentPct;
const props: Record<string, number | string> = value
? (value as Record<string, number | string>)
: { ...existingKf.properties };
const resolvedEase = ease ?? existingKf.ease;
let newScript = script;
if (targetPct !== currentPct) {
newScript = removeKeyframeFromScript(newScript, animationId, currentPct);
newScript = addKeyframeToScript(newScript, animationId, targetPct, props, resolvedEase);
} else {
newScript = updateKeyframeInScript(newScript, animationId, currentPct, props, resolvedEase);
}
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleAddGsapKeyframe(
parsed: ParsedDocument,
animationId: string,
percentage: number,
value: Record<string, unknown>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = addKeyframeToScript(
script,
animationId,
percentage,
value as Record<string, number | string>,
);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleRemoveGsapKeyframe(
parsed: ParsedDocument,
animationId: string,
keyframeIndex: number,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const parsedForWrite = parseGsapScriptAcornForWrite(script);
const located = parsedForWrite?.located.find((l) => l.id === animationId);
const kfs = located?.animation.keyframes?.keyframes;
if (!kfs || keyframeIndex < 0 || keyframeIndex >= kfs.length) return EMPTY;
const pct = kfs[keyframeIndex]!.percentage;
const newScript = removeKeyframeFromScript(script, animationId, pct);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleAddLabel(parsed: ParsedDocument, name: string, position: number): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = addLabelToScript(script, name, position);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleRemoveLabel(parsed: ParsedDocument, name: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = removeLabelFromScript(script, name);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
// ─── Validation (can(op)) ────────────────────────────────────────────────────
/** Returns true if the op can be applied to the current document state. */
// fallow-ignore-next-line complexity
export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
switch (op.type) {
case "setStyle":
@@ -442,10 +677,23 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
case "setVariableValue":
return findRoot(parsed.document) !== null;
case "setCompositionMetadata":
case "setClassStyle":
return true;
// Phase 3b and unknown ops — report false so callers can feature-detect.
// An unknown op type must never silently pass validation only to no-op
// or throw in applyOp (which would violate the can() contract).
case "addGsapTween":
case "addLabel": {
const script = getGsapScript(parsed.document);
if (!script) return false;
const p = parseGsapScriptAcornForWrite(script);
return p !== null && p.hasTimeline;
}
case "setGsapTween":
case "setGsapKeyframe":
case "addGsapKeyframe":
case "removeGsapKeyframe":
case "removeGsapTween":
case "removeLabel":
return getGsapScript(parsed.document) !== null;
// Unknown ops — report false so callers can feature-detect.
default:
return false;
}
+21
View File
@@ -10,6 +10,8 @@
* /elements/{hfId} whole subtree (removeElement)
* /variables/{variableId}
* /metadata/{width|height|duration}
* /script/gsap GSAP inline script textContent
* /style/css <style> element textContent
*
* Override-set key mapping:
* /elements/hf-x/inlineStyles/fontSize "hf-x.style.fontSize"
@@ -20,6 +22,8 @@
* /elements/hf-x "hf-x" (null = removal marker)
* /variables/brand-color-primary "var.brand-color-primary"
* /metadata/width "meta.width"
* /script/gsap "script.gsap"
* /style/css "style.css"
*/
import type { JsonPatchOp, PatchEvent } from "../types.js";
@@ -60,6 +64,14 @@ export function metaPath(field: "width" | "height" | "duration"): string {
return `/metadata/${field}`;
}
export function gsapScriptPath(): string {
return "/script/gsap";
}
export function styleSheetPath(): string {
return "/style/css";
}
// ─── Override-set key mapping ─────────────────────────────────────────────────
/**
@@ -100,6 +112,12 @@ export function pathToKey(path: string): string | null {
const metaMatch = /^\/metadata\/(.+)$/.exec(path);
if (metaMatch) return `meta.${metaMatch[1]}`;
// /script/gsap → "script.gsap"
if (path === "/script/gsap") return "script.gsap";
// /style/css → "style.css"
if (path === "/style/css") return "style.css";
return null;
}
@@ -131,6 +149,9 @@ export function keyToPath(key: string): string | null {
const meta = /^meta\.(width|height|duration)$/.exec(key);
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
if (key === "script.gsap") return gsapScriptPath();
if (key === "style.css") return styleSheetPath();
// Bare element id — removal marker key.
if (!key.includes(".")) return elementPath(key);
+13 -10
View File
@@ -27,7 +27,7 @@ import { buildRoots, flatElements } from "./document.js";
import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp } from "./engine/mutate.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
@@ -129,10 +129,8 @@ class CompositionImpl implements Composition {
}
addGsapTween(target: HfId, tween: GsapTweenSpec): string {
// Phase 3b: AST splice. For now, mint id and pass through.
const tweenId = `tw-${crypto.randomUUID().slice(0, 8)}`;
this.dispatch({ type: "addGsapTween", target, id: tweenId, tween });
return tweenId;
const result = this._dispatch({ type: "addGsapTween", target, tween }, ORIGIN_LOCAL);
return result.meta?.animationId ?? "";
}
setGsapTween(animationId: string, properties: Partial<GsapTweenSpec>): void {
@@ -219,14 +217,13 @@ class CompositionImpl implements Composition {
// ── Dispatch / batch ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
const origin = opts?.origin ?? ORIGIN_LOCAL;
const { forward, inverse } = applyOp(this.parsed, op);
private _dispatch(op: EditOp, origin: unknown): MutationResult {
const result = applyOp(this.parsed, op);
const { forward, inverse } = result;
if (forward.length === 0 && inverse.length === 0) {
// No-op (e.g. Phase 3b op with no implementation yet): still fire change
if (this.batchDepth === 0) this.changeHandlers.forEach((h) => h());
return;
return result;
}
this.elementsCache = null;
@@ -249,6 +246,12 @@ class CompositionImpl implements Composition {
this.patchHandlers.forEach((h) => h(event));
this.changeHandlers.forEach((h) => h());
}
return result;
}
dispatch(op: EditOp, opts?: { origin?: unknown }): void {
this._dispatch(op, opts?.origin ?? ORIGIN_LOCAL);
}
/**
+2 -1
View File
@@ -66,7 +66,7 @@ export type EditOp =
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
| { type: "setVariableValue"; id: string; value: string | number | boolean }
| { type: "addGsapTween"; target: HfId; id: string; tween: GsapTweenSpec }
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
| {
type: "setGsapKeyframe";
@@ -104,6 +104,7 @@ export interface GsapTweenSpec {
properties?: Record<string, unknown>;
repeat?: number;
yoyo?: boolean;
stagger?: number | Record<string, unknown>;
}
// ─── Patch layer (F2: RFC 6902 frozen contract) ───────────────────────────────