docs(sdk): document CompositionVariable, id/variable utilities, and attachSync in canvas guide

Follow-up to the previous commit in this PR — found while auditing whether
any SDK-surface documentation gaps existed beyond this stack:

- types.mdx: EditOp's own union listing was missing declareVariable/
  removeVariable (present in edit-operations.mdx's table but not mirrored
  here). Adds a full CompositionVariable reference section (base fields +
  all 7 variants) since composition.mdx's new declareVariable/listVariables
  docs reference it without it being defined anywhere in the type reference.
- utilities.mdx: documents 6 exported functions with zero prior docs —
  resolveScoped, findById, bareId, escapeHfId, isNewHostBoundary (Id & Scope
  Utilities) and readVariableDefault (Variable Utilities). Pre-existing gaps,
  unrelated to this stack.
- canvas-integration.mdx: adds a "Keeping the preview in sync" section
  covering attachSync right where the guide already sets up preview + comp —
  previously the guide never mentioned it despite being exactly the answer
  to "how do I keep the iframe in sync with edits."
This commit is contained in:
Vance Ingalls
2026-07-09 11:52:14 -07:00
parent 91cc67a007
commit f169d5fad6
3 changed files with 184 additions and 0 deletions
+17
View File
@@ -32,6 +32,23 @@ The callback references `comp` before it is declared — that is intentional and
The `dispatch` callback is optional. Omitting it means `commitPreview()` is a no-op, which is useful if you want to handle op derivation yourself.
## Keeping the preview in sync
Everything above wires up hit-testing and drag — but the iframe still won't reflect edits made any other way (an inspector panel calling `comp.setStyle()` directly, an undo, a collaborator's change replayed via `applyPatches()`). `attachSync` closes that gap: call it once you have both `preview` and `comp`, and every future edit — including undo/redo — mirrors onto the live iframe automatically.
```typescript
const detach = preview.attachSync(comp);
// later, when the editor unmounts or swaps compositions:
detach();
```
`attachSync` does an immediate full sync of `comp`'s current state first (so re-opening a composition with existing overrides isn't a blank iframe), then subscribes to the same `patch` event your other listeners use. You don't need to write your own mirroring code, and you don't need a separate mechanism for undo/redo — both flow through the same subscription. Script-tag edits (GSAP script rewrites) are the one thing it never mirrors, since replaying a live `<script>` tag doesn't re-execute it.
<Note>
Calling `attachSync` again with a different `comp` detaches the previous subscription first — useful if your editor swaps which composition an iframe is bound to without remounting it.
</Note>
## Hit-testing: finding what the user clicked
`preview.elementAtPoint(x, y)` performs a synchronous hit-test at coordinates in the iframe's own coordinate space and returns the nearest `[data-hf-id]` element, or `null` for a transparent hit.
+101
View File
@@ -350,6 +350,105 @@ interface ElasticHold {
---
## CompositionVariable
The full schema for one declared variable — used by `comp.declareVariable()`, returned from `comp.listVariables()`. A closed union discriminated on `type`; every variant shares the same base fields plus its own type-specific ones.
```typescript
interface CompositionVariableBase {
id: string;
type: "string" | "number" | "color" | "boolean" | "enum" | "font" | "image";
label: string;
description?: string;
}
interface StringVariable extends CompositionVariableBase {
type: "string";
default: string;
placeholder?: string;
maxLength?: number;
}
interface NumberVariable extends CompositionVariableBase {
type: "number";
default: number;
min?: number;
max?: number;
step?: number;
unit?: string;
}
interface ColorVariable extends CompositionVariableBase {
type: "color";
default: string;
brandRole?: string;
}
interface BooleanVariable extends CompositionVariableBase {
type: "boolean";
default: boolean;
}
interface EnumVariable extends CompositionVariableBase {
type: "enum";
default: string;
options: { value: string; label: string }[];
}
interface FontVariable extends CompositionVariableBase {
type: "font";
default: string;
source?: string;
default_name?: string;
default_source?: string;
}
interface ImageVariable extends CompositionVariableBase {
type: "image";
default: string;
brandRole?: string;
}
type CompositionVariable =
| StringVariable
| NumberVariable
| ColorVariable
| BooleanVariable
| EnumVariable
| FontVariable
| ImageVariable;
```
<ResponseField name="id" type="string">
Stable identifier, referenced by `setVariableValue`, `getVariableValue`, `removeVariable`, and `var.{id}` override-set keys.
</ResponseField>
<ResponseField name="type" type="string">
Discriminant. Determines which variant's extra fields apply and what shape `default` takes.
</ResponseField>
<ResponseField name="label" type="string">
Human-readable name for a variables panel UI.
</ResponseField>
<ResponseField name="default" type="string | number | boolean">
The variable's fallback value. `font` and `image` variants store their fallback as a plain string here (a font-family name / image URL) — the richer `FontValue`/`ImageValue` object shapes are only used as `setVariableValue`'s runtime argument, not as `default`'s stored type.
</ResponseField>
<ResponseField name="brandRole" type="string (color, image)">
Optional semantic label for brand-system tooling, e.g. `"color:primary"` or `"logo:primary"`.
</ResponseField>
<ResponseField name="options" type="{ value: string; label: string }[] (enum only)">
The selectable choices for an `enum` variable.
</ResponseField>
<ResponseField name="source" type="string (font only)">
Font stylesheet URL (e.g. a Google Fonts CSS link) paired with the `default` font-family name.
</ResponseField>
---
## FontValue
Object value for a `font` composition variable. Always an object — never a raw CSS string.
@@ -619,6 +718,8 @@ 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 | FontValue | ImageValue }
| { type: "declareVariable"; decl: CompositionVariable }
| { type: "removeVariable"; id: string }
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
| { type: "removeGsapTween"; animationId: string }
+66
View File
@@ -230,6 +230,72 @@ const images = all.filter((el) => el.tag === "img");
---
## Id & Scope Utilities
```typescript
import { resolveScoped, findById, bareId, escapeHfId, isNewHostBoundary } from "@hyperframes/sdk";
```
Low-level id-resolution helpers used internally by dispatch, patch replay, and query — exposed for hosts building their own DOM-facing tooling against the same document model.
### resolveScoped
```typescript
function resolveScoped(document: Document, id: string): Element | null;
```
Resolve an `HfId` — bare (`"hf-title"`) or scoped (`"hf-host/hf-leaf"`) — to its `Element`. For an ambiguous bare id (the same id appearing both top-level and inside a sub-composition), prefers the canonical top-level match, matching `getElement()`'s own preference. This is the single resolution rule every mutation and patch-replay path shares — using your own `querySelector` instead can silently target the wrong duplicate.
### findById
```typescript
function findById(document: Document, id: string): Element | null;
```
Thin alias for `resolveScoped` kept for call-site clarity where "find" reads better than "resolve." Identical behavior.
### bareId
```typescript
function bareId(scopedId: string): string;
```
Strip a scoped id down to its leaf segment: `bareId("hf-host/hf-leaf")` returns `"hf-leaf"`. A no-op on an already-bare id.
### escapeHfId
```typescript
function escapeHfId(id: string): string;
```
Escape an id for safe interpolation into a `querySelectorAll` attribute-value selector (backslashes and double quotes). Use this if you're writing a raw `[data-hf-id="..."]` selector yourself instead of going through `resolveScoped`/`findById`.
### isNewHostBoundary
```typescript
function isNewHostBoundary(el: Element): boolean;
```
Returns `true` when `el` is the root of an inlined sub-composition — that is, it carries a `data-composition-file` attribute whose value differs from its parent's (or the parent has none). Use this to detect "entering a new sub-composition" while walking the tree, without hardcoding the attribute name.
---
## Variable Utilities
```typescript
import { readVariableDefault } from "@hyperframes/sdk";
```
### readVariableDefault
```typescript
function readVariableDefault(document: Document, id: string): unknown;
```
Read a declared variable's current `default` value directly from the document's `data-composition-variables` schema attribute, bypassing the session layer. This is the same function `comp.getVariableValue()` calls internally; prefer the typed `Composition` method in session code — use this only when you're working against a raw `Document` outside of an open session (matching `buildDocument`/`buildRoots`'s "same functions the SDK uses internally" pattern above).
---
## Constants
### ORIGIN\_APPLY\_PATCHES