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
+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) ───────────────────────────────