feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C) (#1570)

C1: getElementTimings/setElementTiming typed session methods + setHold typed
wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start
(fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches
a sparse map as one batch → one patch event → one undo step. setHold mirrors
setVariableValue pattern.

Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was
absent, causing undo of duration changes to silently no-op. Added the duration
branch so inverse patches restore data-duration correctly.

C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings()
consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word-
anchored elements get enterAt = wordTimings[k].start + offset; elastic hold =
max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never
timescales animated content. Un-anchored elements keep authored timing (align-on-
adjust). Deterministic + pure: no Date.now, no Math.random, no DOM.

extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for
the getElementTimings labels field.

Tests: timingResolver.test.ts (10 pure-function tests including preview==render
parity golden test); session.timings.test.ts (15 session-layer tests covering
duration-authored, end-authored, label extraction, batching, undo, and setHold
regression).

Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ ·
oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new
inline functions, duplication warn-only pre-existing)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-18 23:05:06 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d0e520dbd9
commit f65e229663
10 changed files with 795 additions and 0 deletions
@@ -1144,3 +1144,61 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
}
}
// ── Label extraction (WS-C) ──────────────────────────────────────────────────
export interface GsapLabelEntry {
name: string;
position: number;
}
/**
* Extract all `tl.addLabel("name", position)` calls from a GSAP script.
*
* Returns labels in source order. Position must be a numeric literal; labels
* with non-numeric positions (e.g. label-relative offsets) are skipped.
*
* Pure — no side effects, no DOM, no Date.now.
*/
export function extractGsapLabels(script: string): GsapLabelEntry[] {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const labels: GsapLabelEntry[] = [];
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
ExpressionStatement(node: any) {
const expr = node.expression;
if (!expr || expr.type !== "CallExpression") return;
const callee = expr.callee;
// Match tl.addLabel(...)
if (
callee?.type !== "MemberExpression" ||
callee.object?.name !== timelineVar ||
callee.property?.name !== "addLabel"
)
return;
const args = expr.arguments ?? [];
const nameNode = args[0];
const posNode = args[1];
if (nameNode?.type !== "Literal" || typeof nameNode.value !== "string") return;
if (!posNode) return;
const pos = resolveNode(posNode, scope);
if (typeof pos !== "number" || !Number.isFinite(pos)) return;
labels.push({ name: nameNode.value, position: pos });
},
});
return labels;
} catch {
return [];
}
}