Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@
// this PR only teaches the server scan to prefer its reported PID, but that
// line shift makes fallow re-flag the inherited probe clones.
"packages/cli/src/server/portUtils.ts",
// iframe.test.ts: the remaining clone groups are pre-existing per-case arrange
// blocks in the selection and draft-loop suites (build an adapter, wire a spy,
// act). Appending the paint-query suite shifts their line numbers and re-flags
// them; each block states its own setup on purpose, which a shared fixture
// would hide.
"packages/sdk/src/adapters/iframe.test.ts",
// gsapParserAcorn.motionEval.test.ts: parallel arrange/act/assert cases for
// the staggered-collection honesty pass (.from reveal vs .to landing on the
// rest pose). Each asserts a distinct keyframe shape; collapsing the shared
Expand Down
59 changes: 59 additions & 0 deletions docs/sdk/guides/canvas-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,65 @@ iframeDoc.addEventListener("click", (e) => {

`resolveNearestHfElement` returns `null` when the walk exits the tree without finding a `[data-hf-id]` node, when the matching node carries `[data-hf-root]` (the root is transparent to selection), or when `isVisible` returns `false` for that node.

## Transparent compositions over other content

A composition authored as an overlay — a small graphic on an otherwise-empty 1080×1920 frame, layered over a video or an avatar — is still a rectangular DOM box covering every pixel of the frame. Without help it swallows every click, and whatever sits beneath it becomes unreachable.

`preview.isProvablyEmptyAt(x, y)` is the question you need answered: is this point provably free of ink, so a click may safely reach what sits beneath? Toggle `pointer-events` on your wrapper from the answer, and let the browser deliver the event to the right target:

```typescript
const wrapper = document.querySelector<HTMLElement>("#composition-wrapper")!;

/**
* Host-page pointer coordinates → the iframe document's own client space, which is what
* the paint query samples against. The iframe renders at the composition's native size and is
* CSS-scaled to fit, so the on-screen scale has to be divided out — skip this and you
* sample the wrong pixel, and pass-through toggles over the wrong regions.
*/
function toCompositionPoint(clientX: number, clientY: number) {
const rect = iframe.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const scaleX = rect.width / compositionNativeWidth;
const scaleY = rect.height / compositionNativeHeight;
if (!scaleX || !scaleY) return null;
return { x: (clientX - rect.left) / scaleX, y: (clientY - rect.top) / scaleY };
}

function updatePassThrough(clientX: number, clientY: number, altKey: boolean) {
const point = toCompositionPoint(clientX, clientY);
// Alt is the escape hatch for grabbing the composition itself in an empty region.
// Every uncertain case — no point, still loading, adapter without the method — is
// falsy here, so the composition stays clickable rather than vanishing.
const passThrough =
!!point && !altKey && !!preview.isProvablyEmptyAt?.(point.x, point.y);
wrapper.style.pointerEvents = passThrough ? "none" : "";
}
```

<Warning>
Listen on the **host document**, not on the wrapper or the iframe. The first time this
sets `pointer-events: none` the wrapper stops receiving events, so a listener attached
there can never turn it back on — the pass-through state sticks.
</Warning>

Three things are easy to get wrong here:

<Steps>
<Step title="Decide before the press, not during it">
The browser picks an event's target before any handler runs, so flipping `pointer-events` inside `mousedown` cannot retarget the click already in flight. Sample the pointer position on `mousemove` and keep the decision current.
</Step>
<Step title="Re-evaluate on every frame, not only on movement">
Animated artwork moves under a stationary cursor. Anything that can change the answer — pointer movement, the Alt key, and the playhead — has to re-run the query from the last known position. Coalesce those triggers into one `requestAnimationFrame` query rather than answering each separately, and short-circuit before the query when the pointer is outside the composition's box: it is a walk over the document, so it does not belong on an ungated per-event path.
</Step>
<Step title="Let the polarity do the work">
`isProvablyEmptyAt` is true only when it has established there is no ink. A document that hasn't loaded, an adapter without the method, and a point you couldn't map all come back falsy — which keeps the composition clickable. Don't invert it into a "does it paint" variable; that reintroduces the bug the polarity removes.
</Step>
</Steps>

Pass `{ fullBleedFraction: 0.9 }` if your editor treats a layer covering nearly the whole frame as background rather than artwork — a common choice, since a full-bleed wrapper is usually scaffolding rather than something the user is pointing at.

Do not reimplement this with `elementsFromPoint`. That stack omits `pointer-events: none` nodes, and a decorative overlay carrying `pointer-events: none` still paints — a z-stack query would report no ink over visible artwork and pass the click through anyway. The paint walk covers element boxes geometrically for that reason.

## Draft loop: 60fps drag without model mutations

The draft loop keeps the model clean during a drag. The SDK is **not** in the 60fps path — you call `preview.applyDraft` on every `pointermove` and `preview.commitPreview` once on `pointerup`. The model sees exactly one `moveElement` op per drag, rather than hundreds.
Expand Down
58 changes: 56 additions & 2 deletions docs/sdk/reference/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Injectable preview surface adapter. Decouples the SDK from the host's rendering
```typescript
interface PreviewAdapter {
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): ElementAtPointResult | null;
isProvablyEmptyAt?(x: number, y: number, opts?: PaintQueryOptions): boolean;
applyDraft(id: string, props: DraftProps): void;
commitPreview(): void;
cancelPreview(): void;
Expand All @@ -100,6 +101,24 @@ interface PreviewAdapter {
Synchronous hit-test at composition coordinates `(x, y)`. Returns the nearest `[data-hf-id]` element under the point, or `null` for a transparent hit (the composition root, an opacity-0 element, or nothing at all). Requires a same-origin iframe — cross-origin access throws a DOMException. The `atTime` option reflects GSAP state at the current playhead; seeking to a speculative time is not supported.
</ParamField>

<ParamField path="isProvablyEmptyAt" type="(x, y, opts?) => boolean">
Optional. Is `(x, y)` provably free of ink — is it safe to let a click pass through to whatever sits beneath? This is the question a host has to answer before a transparent composition layered over other content swallows a click: is the user pointing **at** artwork, or through an empty gap? Geometry alone cannot tell — a composition is mostly full-bleed wrapper `<div>`s that cover every pixel of the frame without painting anything.

**True only when the composition was readable and nothing painted there.** Ink present, a document still loading or unreadable, and an adapter that doesn't implement the method (`preview.isProvablyEmptyAt?.(x, y)` → `undefined` → falsy) all come back falsy. That polarity is deliberate: it puts the burden of proof on passing the click through, so every way of failing keeps the composition clickable rather than making it vanish from under the cursor. The obvious call site is safe by construction:

```typescript
if (preview.isProvablyEmptyAt?.(x, y)) passThrough();
```

Ink is a computed-style test — background colour, background image, visible border, the element's own text, or intrinsic media — with one exception: `<img>` (and the `<img>` inside a `<picture>`) routes through per-pixel alpha, so a transparent PNG paints only where its pixels do. A pixel-verified hit is never discounted by `fullBleedFraction`: box area is not ink area, so a full-frame transparent overlay stays clickable where it is actually opaque.

**Known over-counts** (report ink that isn't there, so a click selects the composition): a `background-image` that is itself mostly transparent reads as painting across its whole box; `<video>`, `<svg>` and `<canvas>` are unconditionally opaque; and an image whose pixels cannot be read — cross-origin without CORS, still loading, rotated, or above the sampler's size budget — falls back to opaque.

**Known under-counts** (miss ink that is there, so a click may pass through): `::before` / `::after` generated content, `box-shadow`, `outline` and `text-decoration` are not tested, and the first three paint outside the border box, so the element is not even a candidate. Content the SDK never stamped is invisible under the default `addressableOnly` — see below.

The walk is **geometric**, not `elementsFromPoint`-based, and is blind to `pointer-events` and `z-index` by design: a decorative overlay carrying `pointer-events: none` still paints, and a z-stack query would report no ink over visible artwork.
</ParamField>

<ParamField path="applyDraft" type="(id: string, props: DraftProps) => void">
Visually translates the preview element at 60fps during a drag: sets the element's CSS `translate` to its pre-drag value composed with the accumulated delta. Works on GSAP-animated elements (a `translate` set after GSAP's first parse composes with the animated transform). The **SDK is not called here** — this is a direct write to the preview surface by your pointer-move handler. Switching `id` mid-drag reverts the previous element's draft first.
</ParamField>
Expand Down Expand Up @@ -159,8 +178,27 @@ interface DraftProps {

`dx` and `dy` are the accumulated drag deltas in composition pixels. `width` and `height` are defined in the interface for forward compatibility but are not yet wired to any op.

### PaintQueryOptions

```typescript
interface PaintQueryOptions {
fullBleedFraction?: number;
addressableOnly?: boolean;
}
```

<ParamField path="fullBleedFraction" type="number" default="0">
A hit whose smallest painting box covers at least this fraction of the composition frame reads as background rather than ink. This is host policy, not a fact about the composition: an editor that treats "you clicked a layer covering the whole frame" as "you clicked the background" passes `0.9`, while a caller asking the literal ink question leaves it at `0`. Nested sub-compositions carry `data-composition-id` too, so the reference frame is the innermost composition root containing the point.
</ParamField>

<ParamField path="addressableOnly" type="boolean" default="true">
Consider only model-addressable elements (`[data-hf-id]`). Stamping happens once, on the document `openComposition` was given, so anything the runtime creates or fetches afterwards is invisible to the default walk: split-text word and character spans (splitting also empties the stamped parent's own text nodes, so the parent stops counting too), cloned nodes, and whole sub-composition scenes mounted from `data-composition-src`. Kinetic typography and registry-mounted lower-thirds — both canonical transparent-overlay content — therefore read as no-ink by default.

Set `false` to widen the walk to every element, which sees that content at the cost of a larger candidate set.
</ParamField>

<Note>
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them.
`ElementAtPointResult` and `DraftProps` are the structural shapes a `PreviewAdapter` produces and consumes. They are **not** re-exported from the `@hyperframes/sdk` barrel — you implement against these shapes rather than importing them. `PaintQueryOptions` **is** re-exported, since callers pass it rather than implement it.
</Note>

---
Expand Down Expand Up @@ -258,7 +296,9 @@ import { createHeadlessAdapter } from "@hyperframes/sdk";
function createHeadlessAdapter(): PreviewAdapter;
```

Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` always returns `null`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.
Returns a no-op `PreviewAdapter` for headless use: agents, CI pipelines, and server-side rendering. All methods are stubs — `elementAtPoint` always returns `null` and `isProvablyEmptyAt` always returns `false`, `applyDraft` and `commitPreview` are no-ops, and the `"selection"` event never fires.

`isProvablyEmptyAt` returns `false` on purpose: an adapter with no surface cannot establish that a point is free of ink, and answering `true` would tell a host it is safe to click through a composition nobody can see.

Pass this adapter when you open a composition for programmatic editing and do not need a live preview surface.

Expand Down Expand Up @@ -297,6 +337,18 @@ Returns a `PreviewAdapter` that bridges the SDK to a same-origin `<iframe>` cont

**Image-alpha hit-testing:** For `<img>` elements, the adapter samples the alpha channel of the pixel under the pointer using an `OffscreenCanvas`. Transparent pixels fall through to the element behind. Cross-origin images that taint the canvas are treated as opaque (safe fallback, logged once per src).

**Paint queries:** `isProvablyEmptyAt` answers whether a point is safe to click through — see the [`PreviewAdapter` interface](#previewadapter) above and the [transparent-overlay recipe](/sdk/guides/canvas-integration#transparent-compositions-over-other-content). The pieces it is built from are importable directly for hosts whose hit-test policy differs:

```typescript
import {
elementPaintsInk,
compositionPaintsAt,
imageAlphaOpaqueAt,
alphaIsOpaque,
mapPointToImagePixel,
} from "@hyperframes/sdk/adapters/iframe";
```

```typescript
import { openComposition, createIframePreviewAdapter } from "@hyperframes/sdk";

Expand Down Expand Up @@ -329,6 +381,8 @@ if (hit) {
| `createMemoryAdapter` | `@hyperframes/sdk` |
| `createHeadlessAdapter` | `@hyperframes/sdk` |
| `createIframePreviewAdapter`, `resolveNearestHfElement` | `@hyperframes/sdk` |
| `PaintQueryOptions` | `@hyperframes/sdk` (type only) |
| `elementPaintsInk`, `compositionPaintsAt`, `imageAlphaOpaqueAt`, `alphaIsOpaque`, `mapPointToImagePixel`, `INTRINSIC_PAINT_TAGS` | `@hyperframes/sdk/adapters/iframe` |
| `createFsAdapter`, `FsAdapterOptions` | `@hyperframes/sdk/adapters/fs` |

<CardGroup cols={2}>
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
"types": "./dist/adapters/headless.d.ts",
"environments": ["browser", "bun", "node"]
},
"./adapters/iframe": {
"source": "./src/adapters/iframe.ts",
"runtime": "./dist/adapters/iframe.js",
"types": "./dist/adapters/iframe.d.ts",
"environments": ["browser", "bun", "node"]
},
"./editing": {
"source": "./src/editing/affordances.ts",
"runtime": "./dist/editing/affordances.js",
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
"import": "./src/adapters/headless.ts",
"types": "./src/adapters/headless.ts"
},
"./adapters/iframe": {
"bun": "./src/adapters/iframe.ts",
"import": "./src/adapters/iframe.ts",
"types": "./src/adapters/iframe.ts"
},
"./editing": {
"bun": "./src/editing/affordances.ts",
"import": "./src/editing/affordances.ts",
Expand All @@ -59,6 +64,10 @@
"import": "./dist/adapters/headless.js",
"types": "./dist/adapters/headless.d.ts"
},
"./adapters/iframe": {
"import": "./dist/adapters/iframe.js",
"types": "./dist/adapters/iframe.d.ts"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/src/adapters/headless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";

import { createHeadlessAdapter } from "./headless.js";

describe("createHeadlessAdapter", () => {
it("is never provably empty", () => {
// An adapter with no surface cannot establish that a point is free of ink. Answering
// true would tell a host it is safe to click through a composition nobody can see.
expect(createHeadlessAdapter().isProvablyEmptyAt?.(10, 10)).toBe(false);
expect(createHeadlessAdapter().elementAtPoint(10, 10)).toBeNull();
});
});
16 changes: 15 additions & 1 deletion packages/sdk/src/adapters/headless.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type {
PreviewAdapter,
ElementAtPointResult,
DraftProps,
PaintQueryOptions,
} from "./types.js";
import type { Composition } from "../types.js";

/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
Expand All @@ -7,6 +12,15 @@ class HeadlessPreviewAdapter implements PreviewAdapter {
return null;
}

/**
* Never provably empty: an adapter with no surface cannot establish that a point is
* free of ink, and claiming otherwise would tell a host it is safe to click through a
* composition nobody can see.
*/
isProvablyEmptyAt(_x: number, _y: number, _opts?: PaintQueryOptions): boolean {
return false;
}

applyDraft(_id: string, _props: DraftProps): void {}

commitPreview(): void {}
Expand Down
Loading
Loading