Skip to content

Commit b158870

Browse files
vanceingallsclaudeMiguel07Alm
authored
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>
1 parent f10f342 commit b158870

7 files changed

Lines changed: 560 additions & 39 deletions

File tree

packages/sdk/src/document.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { parseHTML } from "linkedom";
1212
import { ensureHfIds } from "@hyperframes/core/hf-ids";
13-
import { findRoot, getElementStyles } from "./engine/model.js";
13+
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
1414
import type { HyperFramesElement, SdkDocument } from "./types.js";
1515

1616
// Tags that carry no editable content and must not enter the element tree.
@@ -38,13 +38,23 @@ function ownText(el: Element): string | null {
3838
}
3939

4040
// fallow-ignore-next-line complexity
41-
function buildElement(el: Element): HyperFramesElement | null {
41+
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null {
4242
const tag = el.tagName.toLowerCase();
4343
if (EXCLUDED_TAGS.has(tag)) return null;
4444

4545
const id = el.getAttribute("data-hf-id") ?? "";
4646
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
4747

48+
// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
49+
// The host element itself is in the PARENT scope (no prefix change for its own id).
50+
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;
51+
52+
// Children inherit the scope prefix from their parent.
53+
// If this element is a new host boundary (starts a new sub-comp scope), its
54+
// children use THIS element's scopedId as their prefix.
55+
// Otherwise, children inherit the same prefix that this element used.
56+
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;
57+
4858
const inlineStyles = getElementStyles(el);
4959

5060
const classAttr = el.getAttribute("class") ?? "";
@@ -72,12 +82,13 @@ function buildElement(el: Element): HyperFramesElement | null {
7282

7383
const children: HyperFramesElement[] = [];
7484
for (const child of Array.from(el.children)) {
75-
const built = buildElement(child);
85+
const built = buildElement(child, childPrefix);
7686
if (built) children.push(built);
7787
}
7888

7989
return {
8090
id,
91+
scopedId,
8192
tag,
8293
children,
8394
inlineStyles,
@@ -142,7 +153,7 @@ export function buildRoots(document: Document): HyperFramesElement[] {
142153
const roots: HyperFramesElement[] = [];
143154
if (body) {
144155
for (const child of Array.from(body.children)) {
145-
const built = buildElement(child);
156+
const built = buildElement(child, "");
146157
if (built) roots.push(built);
147158
}
148159
}

packages/sdk/src/engine/model.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null {
3434
return document.querySelector(`[data-hf-id="${escaped}"]`);
3535
}
3636

37+
function escapeHfId(id: string): string {
38+
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
39+
}
40+
41+
/**
42+
* Resolve a bare or scoped hf-id to its DOM element.
43+
*
44+
* Bare id ("hf-x"): equivalent to findById — top-level document search.
45+
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
46+
* into the subtree of the previous match. This unambiguously addresses an
47+
* element inside a sub-composition even when bare ids collide.
48+
*/
49+
export function resolveScoped(document: Document, id: string): Element | null {
50+
const parts = id.split("/");
51+
let context: Element | Document = document;
52+
for (const part of parts) {
53+
const escaped = escapeHfId(part);
54+
const found: Element | null =
55+
context === document
56+
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
57+
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
58+
if (!found) return null;
59+
context = found;
60+
}
61+
return context as Element;
62+
}
63+
64+
/**
65+
* Returns true when this element starts a new sub-composition scope — i.e. it
66+
* is a host element (has data-composition-file) and is NOT the outerHTML
67+
* innerRoot of the SAME sub-composition (same dcf value as parent).
68+
*
69+
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
70+
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
71+
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
72+
*/
73+
export function isNewHostBoundary(el: Element): boolean {
74+
const dcf = el.getAttribute("data-composition-file");
75+
if (!dcf) return false;
76+
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
77+
return dcf !== parentDcf;
78+
}
79+
3780
export function findRoot(document: Document): Element | null {
3881
return (
3982
document.querySelector("[data-hf-root]") ??

packages/sdk/src/engine/mutate.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
1111
import type { ParsedDocument } from "./model.js";
1212
import {
13-
findById,
13+
resolveScoped,
1414
findRoot,
1515
getElementStyles,
1616
setElementStyles,
@@ -194,7 +194,7 @@ function handleSetStyle(
194194
): MutationResult {
195195
const result: MutationResult = { forward: [], inverse: [] };
196196
for (const id of ids) {
197-
const el = findById(parsed.document, id);
197+
const el = resolveScoped(parsed.document, id);
198198
if (!el) continue;
199199
const old = getElementStyles(el);
200200
setElementStyles(el, styles);
@@ -234,7 +234,7 @@ function handleMoveElement(
234234
function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult {
235235
const result: MutationResult = { forward: [], inverse: [] };
236236
for (const id of ids) {
237-
const el = findById(parsed.document, id);
237+
const el = resolveScoped(parsed.document, id);
238238
if (!el) continue;
239239
const oldText = getOwnText(el);
240240
setOwnText(el, value);
@@ -259,7 +259,7 @@ function handleSetAttribute(
259259
validateSetAttribute(name, value);
260260
const result: MutationResult = { forward: [], inverse: [] };
261261
for (const id of ids) {
262-
const el = findById(parsed.document, id);
262+
const el = resolveScoped(parsed.document, id);
263263
if (!el) continue;
264264
const oldValue = el.getAttribute(name);
265265
const path = attrPath(id, name);
@@ -293,7 +293,7 @@ function handleSetTiming(
293293
let currentScript = origScript;
294294

295295
for (const id of ids) {
296-
const el = findById(parsed.document, id);
296+
const el = resolveScoped(parsed.document, id);
297297
if (!el) continue;
298298

299299
const oldStartStr = el.getAttribute("data-start");
@@ -373,7 +373,7 @@ function handleSetHold(
373373
): MutationResult {
374374
const result: MutationResult = { forward: [], inverse: [] };
375375
for (const id of ids) {
376-
const el = findById(parsed.document, id);
376+
const el = resolveScoped(parsed.document, id);
377377
if (!el) continue;
378378

379379
const fields: Array<["start" | "end" | "fill", string]> = [
@@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
401401
let currentScript = origScript;
402402

403403
for (const id of ids) {
404-
const el = findById(parsed.document, id);
404+
const el = resolveScoped(parsed.document, id);
405405
if (!el) continue;
406406
const parentEl = el.parentElement;
407407
const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
408408
const siblingIndex = getSiblingIndex(el);
409409
const html = el.outerHTML;
410410

411+
// Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
412+
// removes animations targeting any sub-composition element, not just the host.
413+
const subtreeIds = collectSubtreeHfIds(el);
414+
411415
el.remove();
412416

413417
const path = elementPath(id);
414418
result.forward.push(patchRemove(path));
415419
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
416420

417-
if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
421+
if (currentScript) {
422+
for (const subtreeId of subtreeIds) {
423+
currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
424+
}
425+
}
418426
}
419427

420428
if (origScript && currentScript && currentScript !== origScript) {
@@ -509,10 +517,24 @@ function selectorMatchesId(selector: string, id: HfId): boolean {
509517
);
510518
}
511519

512-
// v1 limitation: uses bare-id matching across the whole script, so a selector targeting
513-
// "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf" and any other
514-
// element whose scoped or bare id matches "hf-leaf". Acceptable for typical single-comp
515-
// use; sub-composition authors with leaf-id collisions should use fully-qualified selectors.
520+
// v1 limitation: selectorMatchesId uses bare-id matching across the whole script, so a
521+
// selector targeting "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf"
522+
// and any other element whose scoped or bare id matches "hf-leaf". Acceptable for typical
523+
// single-comp use; sub-composition authors with leaf-id collisions should use
524+
// fully-qualified selectors.
525+
526+
/** Collect all bare data-hf-id values from el and all its descendants. */
527+
function collectSubtreeHfIds(el: Element): string[] {
528+
const ids: string[] = [];
529+
const own = el.getAttribute("data-hf-id");
530+
if (own) ids.push(own);
531+
for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
532+
const id = child.getAttribute("data-hf-id");
533+
if (id) ids.push(id);
534+
}
535+
return ids;
536+
}
537+
516538
function cascadeRemoveAnimations(script: string, id: HfId): string {
517539
const parsedGsap = parseGsapScriptAcornForWrite(script);
518540
if (!parsedGsap) return script;
@@ -576,8 +598,11 @@ function handleAddGsapTween(
576598
? ((tween.toProperties ?? {}) as Record<string, number | string>)
577599
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
578600

601+
// Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
602+
// selector — only the leaf part is written as data-hf-id on the DOM element.
603+
const bareTarget = target.includes("/") ? (target.split("/").at(-1) ?? target) : target;
579604
const animation: Omit<GsapAnimation, "id"> = {
580-
targetSelector: `[data-hf-id="${target}"]`,
605+
targetSelector: `[data-hf-id="${bareTarget}"]`,
581606
method: tween.method,
582607
position: tween.position ?? 0,
583608
...(tween.duration !== undefined ? { duration: tween.duration } : {}),
@@ -753,7 +778,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
753778
case "removeElement": {
754779
const ids = targets(op.target);
755780
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
756-
const missing = ids.filter((id) => findById(parsed.document, id) === null);
781+
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
757782
if (missing.length > 0)
758783
return canErr(
759784
"E_TARGET_NOT_FOUND",
@@ -771,6 +796,12 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
771796
return CAN_OK;
772797
case "addGsapTween":
773798
case "addLabel": {
799+
if (op.type === "addGsapTween" && resolveScoped(parsed.document, op.target) === null)
800+
return canErr(
801+
"E_TARGET_NOT_FOUND",
802+
`Element not found: ${op.target}.`,
803+
"Verify the id against comp.getElements() or comp.find().",
804+
);
774805
const script = getGsapScript(parsed.document);
775806
if (!script)
776807
return canErr(

packages/sdk/src/engine/patches.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,45 @@ import type { JsonPatchOp, PatchEvent } from "../types.js";
3030

3131
// ─── Path builders ────────────────────────────────────────────────────────────
3232

33+
/**
34+
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
35+
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
36+
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
37+
*/
38+
function escapeIdForPath(id: string): string {
39+
return id.replace(/~/g, "~0").replace(/\//g, "~1");
40+
}
41+
42+
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
43+
function decodePathSegment(segment: string): string {
44+
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
45+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
46+
}
47+
3348
export function stylePath(id: string, prop: string): string {
34-
return `/elements/${id}/inlineStyles/${prop}`;
49+
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
3550
}
3651

3752
export function textPath(id: string): string {
38-
return `/elements/${id}/text`;
53+
return `/elements/${escapeIdForPath(id)}/text`;
3954
}
4055

4156
export function attrPath(id: string, name: string): string {
4257
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
43-
const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1");
44-
return `/elements/${id}/attributes/${escaped}`;
58+
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
59+
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
4560
}
4661

4762
export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
48-
return `/elements/${id}/timing/${field}`;
63+
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
4964
}
5065

5166
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
52-
return `/elements/${id}/hold/${field}`;
67+
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
5368
}
5469

5570
export function elementPath(id: string): string {
56-
return `/elements/${id}`;
71+
return `/elements/${escapeIdForPath(id)}`;
5772
}
5873

5974
export function variablePath(id: string): string {
@@ -80,29 +95,30 @@ export function styleSheetPath(): string {
8095
*/
8196
export function pathToKey(path: string): string | null {
8297
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
98+
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
8399
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
84-
if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`;
100+
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
85101

86102
// /elements/{id}/text → "{id}.text"
87103
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
88-
if (textMatch) return `${textMatch[1]}.text`;
104+
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
89105

90106
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
91107
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
92-
if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`;
108+
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
93109

94110
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
95111
// Note: field "end" maps to the computed data-end attribute value.
96112
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
97-
if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`;
113+
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
98114

99115
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
100116
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
101-
if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`;
117+
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
102118

103119
// /elements/{id} (whole element) → "{id}"
104120
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
105-
if (elemMatch) return elemMatch[1] ?? null;
121+
if (elemMatch) return decodePathSegment(elemMatch[1]!);
106122

107123
// /variables/{id} → "var.{id}"
108124
const varMatch = /^\/variables\/(.+)$/.exec(path);
@@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null {
133149
if (text?.[1]) return textPath(text[1]);
134150

135151
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
136-
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
137-
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
138-
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
152+
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
153+
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
154+
// the already-encoded attr segment. Reconstruct manually.
155+
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
139156

140157
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
141158
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");

0 commit comments

Comments
 (0)