feat(sdk): stage 6 — sub-composition scoped ids (F9) (#1434)

* feat(sdk): stage 6 — sub-composition scoped ids (F9)

Adds fully-qualified scoped ids for addressing elements inside inlined
sub-compositions, so callers can target "hf-HOST/hf-LEAF" unambiguously
even when bare hf-ids collide across sub-composition boundaries.

Changes:
- model.ts: resolveScoped() traverses id segments through nested subtrees;
  isNewHostBoundary() detects host boundaries (dcf ≠ parent dcf handles
  outerHTML innerRoot edge case)
- types.ts: HyperFramesElement gains scopedId field
- document.ts: buildElement carries scopePrefix, propagates childPrefix
  at host boundaries; buildRoots starts with ""
- patches.ts: RFC 6902 escapeIdForPath / decodePathSegment for scoped ids
  containing "/"; all path builders and pathToKey/keyToPath updated
- session.ts: getElement() matches by scopedId; find() returns scopedIds;
  orphan cleanup decodes RFC 6902 before key comparison, preserves removal
  markers, purges property sub-keys for both bare and scoped ids
- mutate.ts: all element handlers use resolveScoped instead of findById;
  handleRemoveElement collects full subtree hf-ids before removal for
  complete GSAP animation cascade (Q3 fix); validateOp uses resolveScoped

20 new contract tests in session.subcomp.test.ts covering resolveScoped,
scopedId propagation, dispatch to scoped targets, RFC 6902 patch encoding,
override-set key format, orphan purge, and serialize stability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk): add find({ composition }) filter — Stage 6 WS-C completion

Closes the last headless-testable Stage 6 gap (F9 workstream C).

`find({ composition: "hf-host" })` returns all scopedIds whose prefix
matches the given host id — i.e. every element mounted inside that
sub-composition, at any depth. Combinable with other FindQuery fields
(tag, text, name, track). 3 new contract tests in session.subcomp.test.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): addGsapTween resolves scoped id to bare leaf; validateOp checks target exists

- handleAddGsapTween: strip host prefix for scoped ids (hf-host/hf-leaf →
  selector [data-hf-id="hf-leaf"]) — DOM element carries only the leaf part
- validateOp addGsapTween: call resolveScoped to surface E_TARGET_NOT_FOUND
  before the GSAP script checks (previously can() returned ok for missing targets)
- patches.ts pathToKey: remove dead ?? null (decodePathSegment never returns undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-15 02:21:36 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent f10f3425a5
commit b158870d8f
7 changed files with 560 additions and 39 deletions
+43
View File
@@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null {
return document.querySelector(`[data-hf-id="${escaped}"]`);
}
function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Resolve a bare or scoped hf-id to its DOM element.
*
* Bare id ("hf-x"): equivalent to findById — top-level document search.
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
* into the subtree of the previous match. This unambiguously addresses an
* element inside a sub-composition even when bare ids collide.
*/
export function resolveScoped(document: Document, id: string): Element | null {
const parts = id.split("/");
let context: Element | Document = document;
for (const part of parts) {
const escaped = escapeHfId(part);
const found: Element | null =
context === document
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
if (!found) return null;
context = found;
}
return context as Element;
}
/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
* innerRoot of the SAME sub-composition (same dcf value as parent).
*
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
*/
export function isNewHostBoundary(el: Element): boolean {
const dcf = el.getAttribute("data-composition-file");
if (!dcf) return false;
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
return dcf !== parentDcf;
}
export function findRoot(document: Document): Element | null {
return (
document.querySelector("[data-hf-root]") ??
+45 -14
View File
@@ -10,7 +10,7 @@
import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
import type { ParsedDocument } from "./model.js";
import {
findById,
resolveScoped,
findRoot,
getElementStyles,
setElementStyles,
@@ -194,7 +194,7 @@ function handleSetStyle(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const old = getElementStyles(el);
setElementStyles(el, styles);
@@ -234,7 +234,7 @@ function handleMoveElement(
function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldText = getOwnText(el);
setOwnText(el, value);
@@ -259,7 +259,7 @@ function handleSetAttribute(
validateSetAttribute(name, value);
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldValue = el.getAttribute(name);
const path = attrPath(id, name);
@@ -293,7 +293,7 @@ function handleSetTiming(
let currentScript = origScript;
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldStartStr = el.getAttribute("data-start");
@@ -373,7 +373,7 @@ function handleSetHold(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const fields: Array<["start" | "end" | "fill", string]> = [
@@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
let currentScript = origScript;
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const parentEl = el.parentElement;
const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
const siblingIndex = getSiblingIndex(el);
const html = el.outerHTML;
// Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
// removes animations targeting any sub-composition element, not just the host.
const subtreeIds = collectSubtreeHfIds(el);
el.remove();
const path = elementPath(id);
result.forward.push(patchRemove(path));
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
if (currentScript) {
for (const subtreeId of subtreeIds) {
currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
}
}
}
if (origScript && currentScript && currentScript !== origScript) {
@@ -509,10 +517,24 @@ function selectorMatchesId(selector: string, id: HfId): boolean {
);
}
// v1 limitation: uses bare-id matching across the whole script, so a selector targeting
// "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf" and any other
// element whose scoped or bare id matches "hf-leaf". Acceptable for typical single-comp
// use; sub-composition authors with leaf-id collisions should use fully-qualified selectors.
// v1 limitation: selectorMatchesId uses bare-id matching across the whole script, so a
// selector targeting "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf"
// and any other element whose scoped or bare id matches "hf-leaf". Acceptable for typical
// single-comp use; sub-composition authors with leaf-id collisions should use
// fully-qualified selectors.
/** Collect all bare data-hf-id values from el and all its descendants. */
function collectSubtreeHfIds(el: Element): string[] {
const ids: string[] = [];
const own = el.getAttribute("data-hf-id");
if (own) ids.push(own);
for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
const id = child.getAttribute("data-hf-id");
if (id) ids.push(id);
}
return ids;
}
function cascadeRemoveAnimations(script: string, id: HfId): string {
const parsedGsap = parseGsapScriptAcornForWrite(script);
if (!parsedGsap) return script;
@@ -576,8 +598,11 @@ function handleAddGsapTween(
? ((tween.toProperties ?? {}) as Record<string, number | string>)
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
// Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
// selector — only the leaf part is written as data-hf-id on the DOM element.
const bareTarget = target.includes("/") ? (target.split("/").at(-1) ?? target) : target;
const animation: Omit<GsapAnimation, "id"> = {
targetSelector: `[data-hf-id="${target}"]`,
targetSelector: `[data-hf-id="${bareTarget}"]`,
method: tween.method,
position: tween.position ?? 0,
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
@@ -753,7 +778,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeElement": {
const ids = targets(op.target);
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
const missing = ids.filter((id) => findById(parsed.document, id) === null);
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
if (missing.length > 0)
return canErr(
"E_TARGET_NOT_FOUND",
@@ -771,6 +796,12 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
return CAN_OK;
case "addGsapTween":
case "addLabel": {
if (op.type === "addGsapTween" && resolveScoped(parsed.document, op.target) === null)
return canErr(
"E_TARGET_NOT_FOUND",
`Element not found: ${op.target}.`,
"Verify the id against comp.getElements() or comp.find().",
);
const script = getGsapScript(parsed.document);
if (!script)
return canErr(
+33 -16
View File
@@ -30,30 +30,45 @@ import type { JsonPatchOp, PatchEvent } from "../types.js";
// ─── Path builders ────────────────────────────────────────────────────────────
/**
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
*/
function escapeIdForPath(id: string): string {
return id.replace(/~/g, "~0").replace(/\//g, "~1");
}
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
function decodePathSegment(segment: string): string {
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function stylePath(id: string, prop: string): string {
return `/elements/${id}/inlineStyles/${prop}`;
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
}
export function textPath(id: string): string {
return `/elements/${id}/text`;
return `/elements/${escapeIdForPath(id)}/text`;
}
export function attrPath(id: string, name: string): string {
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${id}/attributes/${escaped}`;
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
}
export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
return `/elements/${id}/timing/${field}`;
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
}
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
return `/elements/${id}/hold/${field}`;
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
}
export function elementPath(id: string): string {
return `/elements/${id}`;
return `/elements/${escapeIdForPath(id)}`;
}
export function variablePath(id: string): string {
@@ -80,29 +95,30 @@ export function styleSheetPath(): string {
*/
export function pathToKey(path: string): string | null {
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`;
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
// /elements/{id}/text → "{id}.text"
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
if (textMatch) return `${textMatch[1]}.text`;
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`;
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
// Note: field "end" maps to the computed data-end attribute value.
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`;
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`;
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
// /elements/{id} (whole element) → "{id}"
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return elemMatch[1] ?? null;
if (elemMatch) return decodePathSegment(elemMatch[1]!);
// /variables/{id} → "var.{id}"
const varMatch = /^\/variables\/(.+)$/.exec(path);
@@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null {
if (text?.[1]) return textPath(text[1]);
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");