From 15b4f97ef7e16cf8462c970c8df4ab2ca46b0804 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 28 Jul 2026 19:46:27 -0300 Subject: [PATCH] feat(morph): add prepared DOM targets --- AGENTS.md | 4 + packages/morph/README.md | 5 + packages/morph/scripts/certify-package.mjs | 81 ++++++++++ packages/morph/src/render/index.ts | 7 + .../src/render/preparedDomTarget.test.ts | 149 +++++++++++++++++ .../morph/src/render/preparedDomTarget.ts | 150 ++++++++++++++++++ packages/morph/src/render/types.ts | 37 +++++ website/src/content/docs/guides/morph.mdx | 6 + 8 files changed, 439 insertions(+) create mode 100644 packages/morph/src/render/preparedDomTarget.test.ts create mode 100644 packages/morph/src/render/preparedDomTarget.ts diff --git a/AGENTS.md b/AGENTS.md index b12b0de8..cc84c030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ React or Vue wrappers. prepared packages, mounts one retained PolyCSS graph, and exposes caller-driven runtimes for morphs, controls, springs, animation, skinning, and prepared playback. +- `createPolyMorphPreparedDomTarget` adopts a caller-owned retained graph as + source-ordered model, shape, and leaf targets. It tracks requested values for + sparse write deduplication, preserves element identity, invalidates writers + when destroyed, and leaves DOM teardown to the caller. - A mount uses PolyCSS's public solid-triangle support check, so Firefox keeps its border-triangle path while WebKit/Safari selects each leaf's prepared polygon-sized atlas slice. Image paint comes only from loader-verified bytes: diff --git a/packages/morph/README.md b/packages/morph/README.md index 4493246d..d53ade8e 100644 --- a/packages/morph/README.md +++ b/packages/morph/README.md @@ -81,6 +81,11 @@ The browser API is intentionally imperative: `mounted.apply(sample.update)` succeeds; - call `mounted.destroy()` at teardown. +Consumers that already own a retained DOM graph can adopt its source-ordered +model, shape, and leaf elements with `createPolyMorphPreparedDomTarget`. +The target owns write deduplication and invalidation, while the consumer +continues to own the elements and their teardown. + Morph owns no `requestAnimationFrame` loop, interval, or other scheduler. A mounted model keeps the same leaf elements for its lifetime. Runtime updates do not rebuild topology, add or remove leaves, construct image resources, or diff --git a/packages/morph/scripts/certify-package.mjs b/packages/morph/scripts/certify-package.mjs index ffad921c..08f3d9c6 100644 --- a/packages/morph/scripts/certify-package.mjs +++ b/packages/morph/scripts/certify-package.mjs @@ -159,8 +159,11 @@ async function writeConsumer( import { createPolyOrthographicCamera } from "@layoutit/polycss"; import { createPolyMorphDeformationRuntime, + createPolyMorphPreparedDomTarget, mountPolyMorphModel, type PolyMorphModel, + type PolyMorphPreparedDomTarget, + type PolyMorphPreparedElementTarget, } from "@layoutit/polycss-morph"; import { preparePolyMorphModel, @@ -169,10 +172,23 @@ import { declare const host: HTMLElement; declare const model: PolyMorphModel; +declare const modelElement: HTMLElement; +declare const shapeElement: HTMLElement; +declare const leafElement: HTMLElement; const camera = createPolyOrthographicCamera({ zoom: 1 }); const mounted = mountPolyMorphModel(host, model, { camera }); const runtime = createPolyMorphDeformationRuntime(model); +const preparedTarget: PolyMorphPreparedDomTarget = + createPolyMorphPreparedDomTarget({ + model: { + element: modelElement, + writeTransform: () => true, + }, + shapes: [{ element: shapeElement }], + leaves: [{ element: leafElement }], + }); +const preparedLeaf: PolyMorphPreparedElementTarget = preparedTarget.leaves[0]!; const options: PolyMorphPrepareOptions = { configPath: "./source/prepare.json", outputRoot: "./model/package", @@ -180,6 +196,7 @@ const options: PolyMorphPrepareOptions = { void runtime.sample({ tick: 0 }); void mounted; +void preparedLeaf; void preparePolyMorphModel(options); `); await writeFile(resolve(consumerRoot, "prepare-smoke.mjs"), ` @@ -264,6 +281,7 @@ const fetchImpl = async (input) => { const { createPolyMorphDeformationRuntime, + createPolyMorphPreparedDomTarget, loadPolyMorphPackage, mountPolyMorphModel, } = await import("@layoutit/polycss-morph"); @@ -349,6 +367,67 @@ const fallbackDom = [...fallbackMounted.leafHandles.values()] && element.style.getPropertyValue("-webkit-mask-image").includes("blob:"); }); +const adoptedHost = document.createElement("main"); +const adoptedModel = document.createElement("div"); +const adoptedShape = document.createElement("div"); +const adoptedLeaf = document.createElement("s"); +document.body.appendChild(adoptedHost); +adoptedHost.appendChild(adoptedModel); +adoptedModel.appendChild(adoptedShape); +adoptedShape.appendChild(adoptedLeaf); +let adoptedModelTransform; +let adoptedModelWrites = 0; +const preparedTarget = createPolyMorphPreparedDomTarget({ + model: { + element: adoptedModel, + writeTransform(transform) { + if (adoptedModelTransform === transform) return false; + adoptedModelTransform = transform; + adoptedModel.style.transform = transform; + adoptedModelWrites += 1; + return true; + }, + }, + shapes: [{ element: adoptedShape }], + leaves: [{ element: adoptedLeaf }], +}); +const adoptedIdentityWrite = preparedTarget.model.writeTransform(""); +const adoptedIdentityRepeat = preparedTarget.model.writeTransform(""); +const adoptedLeafWrite = preparedTarget.leaves[0].writeTransform(""); +adoptedLeaf.style.transform = "scale(2)"; +const adoptedLeafRepeat = preparedTarget.leaves[0].writeTransform(""); +const adoptedVisibilityWrite = preparedTarget.leaves[0].writeVisibility(false); +const adoptedVisibilityRepeat = preparedTarget.leaves[0].writeVisibility(false); +const adoptedOpacityWrite = preparedTarget.leaves[0].writeOpacity(0.5); +const adoptedOpacityRepeat = preparedTarget.leaves[0].writeOpacity(0.5); +const adoptedImageWrite = preparedTarget.leaves[0].writeImagePositionY("-4px"); +const adoptedImageRepeat = preparedTarget.leaves[0].writeImagePositionY("-4px"); +preparedTarget.assertStableDomIdentity(); +preparedTarget.destroy(); +let destroyedTargetRejectsWrites = false; +try { + preparedTarget.leaves[0].writeVisibility(true); +} catch { + destroyedTargetRejectsWrites = true; +} +const preparedTargetCertified = adoptedIdentityWrite + && !adoptedIdentityRepeat + && adoptedModelWrites === 1 + && adoptedLeafWrite + && !adoptedLeafRepeat + && adoptedLeaf.style.transform === "scale(2)" + && adoptedVisibilityWrite + && !adoptedVisibilityRepeat + && adoptedOpacityWrite + && !adoptedOpacityRepeat + && adoptedImageWrite + && !adoptedImageRepeat + && preparedTarget.destroyed + && destroyedTargetRejectsWrites + && adoptedModel.parentElement === adoptedHost + && adoptedShape.parentElement === adoptedModel + && adoptedLeaf.parentElement === adoptedShape; + if ( mounted.leafHandles.size !== loaded.model.render.leaves.length || frame.dirtyLeafIds.length === 0 @@ -363,6 +442,7 @@ if ( || fallbackApplied.atlasRedraws !== 0 || fallbackApplied.topologyConstructions !== 0 || fallbackApplied.schedulerCallbacks !== 0 + || !preparedTargetCertified ) { throw new Error("packed browser entry violated the retained-model contract"); } @@ -378,6 +458,7 @@ console.log(JSON.stringify({ solidLeaves, polygonSizedFallbacks, canonicalDom, + preparedTargetCertified, applied, stats: mounted.stats, fallback: { diff --git a/packages/morph/src/render/index.ts b/packages/morph/src/render/index.ts index 3089d902..cdadb9be 100644 --- a/packages/morph/src/render/index.ts +++ b/packages/morph/src/render/index.ts @@ -1,4 +1,5 @@ export { mountPolyMorphModel } from "./mount.js"; +export { createPolyMorphPreparedDomTarget } from "./preparedDomTarget.js"; export { PolyMorphRenderError } from "./renderError.js"; export type { PolyMorphApplyResult, @@ -7,6 +8,12 @@ export type { PolyMorphLeafUpdate, PolyMorphMountedModel, PolyMorphMountOptions, + PolyMorphPreparedDomTarget, + PolyMorphPreparedDomTargetInput, + PolyMorphPreparedElementTarget, + PolyMorphPreparedElementTargetInput, + PolyMorphPreparedModelTarget, + PolyMorphPreparedModelTargetInput, PolyMorphRenderStats, PolyMorphRetainedUpdate, PolyMorphShapeUpdate, diff --git a/packages/morph/src/render/preparedDomTarget.test.ts b/packages/morph/src/render/preparedDomTarget.test.ts new file mode 100644 index 00000000..7d3c30cc --- /dev/null +++ b/packages/morph/src/render/preparedDomTarget.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + createPolyMorphPreparedDomTarget, + PolyMorphRenderError, +} from "./index.js"; + +function createGraph() { + const host = document.createElement("main"); + const model = document.createElement("div"); + const shape = document.createElement("div"); + const leaf = document.createElement("u"); + document.body.appendChild(host); + host.appendChild(model); + model.appendChild(shape); + shape.appendChild(leaf); + return { host, model, shape, leaf }; +} + +function createTarget(writeModelTransform?: (transform: string) => boolean) { + const graph = createGraph(); + let modelTransform: string | undefined; + let modelWrites = 0; + const target = createPolyMorphPreparedDomTarget({ + model: { + element: graph.model, + writeTransform(transform) { + if (writeModelTransform) return writeModelTransform(transform); + if (modelTransform === transform) return false; + modelTransform = transform; + graph.model.style.transform = transform; + modelWrites += 1; + return true; + }, + }, + shapes: [{ element: graph.shape }], + leaves: [{ element: graph.leaf }], + }); + return { ...graph, target, get modelWrites() { return modelWrites; } }; +} + +describe("createPolyMorphPreparedDomTarget", () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + it("writes through stable indexed targets and deduplicates requested values", () => { + const graph = createTarget(); + const { target } = graph; + + expect(Object.isFrozen(target.shapes)).toBe(true); + expect(Object.isFrozen(target.leaves)).toBe(true); + expect(target.shapes[0]!.element).toBe(graph.shape); + expect(target.leaves[0]!.element).toBe(graph.leaf); + expect(target.model.writeTransform("")).toBe(true); + expect(target.model.writeTransform("")).toBe(false); + expect(graph.modelWrites).toBe(1); + + expect(target.shapes[0]!.writeTransform("")).toBe(true); + expect(target.shapes[0]!.writeTransform("")).toBe(false); + graph.shape.style.transform = "scale(2)"; + expect(target.shapes[0]!.writeTransform("")).toBe(false); + expect(graph.shape.style.transform).toBe("scale(2)"); + + const transform = "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,2,3,4,1)"; + expect(target.leaves[0]!.writeTransform(transform)).toBe(true); + expect(target.leaves[0]!.writeTransform(transform)).toBe(false); + expect(target.leaves[0]!.writeVisibility(false)).toBe(true); + expect(target.leaves[0]!.writeVisibility(false)).toBe(false); + expect(target.leaves[0]!.writeOpacity(0.5)).toBe(true); + expect(target.leaves[0]!.writeOpacity(0.5)).toBe(false); + expect(target.leaves[0]!.writeImagePositionY("-4px")).toBe(true); + expect(target.leaves[0]!.writeImagePositionY("-4px")).toBe(false); + expect(() => target.assertStableDomIdentity()).not.toThrow(); + expect(graph.host.querySelectorAll("*")).toHaveLength(3); + }); + + it("rejects invalid target inputs and writer values", () => { + expect(() => createPolyMorphPreparedDomTarget(null as never)) + .toThrowError(PolyMorphRenderError); + expect(() => createPolyMorphPreparedDomTarget({ + model: { + element: document.createElement("div"), + writeTransform: () => true, + }, + shapes: null as never, + leaves: [], + })).toThrowError(PolyMorphRenderError); + expect(() => createPolyMorphPreparedDomTarget({ + model: { + element: document.createElement("div"), + writeTransform: () => true, + }, + shapes: [{ element: {} as HTMLElement }], + leaves: [], + })).toThrowError(PolyMorphRenderError); + expect(() => createPolyMorphPreparedDomTarget({ + model: { + element: document.createElement("div"), + writeTransform: null as never, + }, + shapes: [], + leaves: [], + })).toThrowError(PolyMorphRenderError); + + const graph = createTarget(() => "changed" as unknown as boolean); + expect(() => graph.target.model.writeTransform("")) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeTransform(null as never)) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeVisibility("yes" as never)) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeOpacity(Number.NaN)) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeOpacity(-0.1)) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeOpacity(1.1)) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeImagePositionY("")) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.leaves[0]!.writeImagePositionY(null as never)) + .toThrowError(PolyMorphRenderError); + }); + + it.each(["model", "shape", "leaf"] as const)( + "detects %s parent identity drift", + (kind) => { + const graph = createTarget(); + const element = graph[kind]; + document.body.appendChild(element); + expect(() => graph.target.assertStableDomIdentity()) + .toThrowError(PolyMorphRenderError); + }, + ); + + it("invalidates writers without removing caller-owned DOM", () => { + const graph = createTarget(); + graph.target.destroy(); + graph.target.destroy(); + + expect(graph.target.destroyed).toBe(true); + expect(graph.host.querySelectorAll("*")).toHaveLength(3); + expect(() => graph.target.assertStableDomIdentity()) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.model.writeTransform("")) + .toThrowError(PolyMorphRenderError); + expect(() => graph.target.shapes[0]!.writeVisibility(true)) + .toThrowError(PolyMorphRenderError); + }); +}); diff --git a/packages/morph/src/render/preparedDomTarget.ts b/packages/morph/src/render/preparedDomTarget.ts new file mode 100644 index 00000000..a67172ec --- /dev/null +++ b/packages/morph/src/render/preparedDomTarget.ts @@ -0,0 +1,150 @@ +import { PolyMorphRenderError } from "./renderError.js"; +import type { + PolyMorphPreparedDomTarget, + PolyMorphPreparedDomTargetInput, + PolyMorphPreparedElementTarget, +} from "./types.js"; + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRenderError(code, path, message); +} + +function element(value: unknown, path: string): HTMLElement { + const target = value as HTMLElement | null; + if ( + !target + || typeof target !== "object" + || !target.ownerDocument + || typeof target.style?.setProperty !== "function" + ) { + fail("invalid-target", path, "expected an HTMLElement"); + } + return target; +} + +function transform(value: unknown, path: string): string { + if (typeof value !== "string") { + fail("invalid-transform", path, "expected a CSS transform string"); + } + return value; +} + +function createElementTarget( + targetElement: HTMLElement, + path: string, + assertAlive: () => void, +): PolyMorphPreparedElementTarget { + let requestedTransform: string | undefined; + let requestedVisibility: boolean | undefined; + let requestedOpacity: number | undefined; + let requestedImagePositionY: string | undefined; + return Object.freeze({ + element: targetElement, + writeTransform(value: string): boolean { + assertAlive(); + const next = transform(value, `${path}.transform`); + if (requestedTransform === next) return false; + targetElement.style.transform = next; + requestedTransform = next; + return true; + }, + writeVisibility(visible: boolean): boolean { + assertAlive(); + if (typeof visible !== "boolean") { + fail("invalid-visibility", `${path}.visible`, "expected a boolean"); + } + if (requestedVisibility === visible) return false; + targetElement.style.visibility = visible ? "visible" : "hidden"; + requestedVisibility = visible; + return true; + }, + writeOpacity(opacity: number): boolean { + assertAlive(); + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) { + fail("invalid-opacity", `${path}.opacity`, "expected a value in [0, 1]"); + } + if (requestedOpacity === opacity) return false; + targetElement.style.opacity = String(opacity); + requestedOpacity = opacity; + return true; + }, + writeImagePositionY(position: string): boolean { + assertAlive(); + if (typeof position !== "string" || position.length === 0) { + fail("invalid-image-position", `${path}.imagePositionY`, "expected a CSS position"); + } + if (requestedImagePositionY === position) return false; + targetElement.style.backgroundPositionY = position; + requestedImagePositionY = position; + return true; + }, + }); +} + +export function createPolyMorphPreparedDomTarget( + input: PolyMorphPreparedDomTargetInput, +): PolyMorphPreparedDomTarget { + if (!input || typeof input !== "object" || Array.isArray(input)) { + fail("invalid-target", "$", "expected a prepared DOM target"); + } + const modelElement = element(input.model?.element, "$.model.element"); + if (typeof input.model?.writeTransform !== "function") { + fail("invalid-target", "$.model.writeTransform", "expected a transform writer"); + } + if (!Array.isArray(input.shapes) || !Array.isArray(input.leaves)) { + fail("invalid-target", "$", "expected shape and leaf target arrays"); + } + const shapeElements = input.shapes.map((target, index) => + element(target?.element, `$.shapes[${index}].element`)); + const leafElements = input.leaves.map((target, index) => + element(target?.element, `$.leaves[${index}].element`)); + const modelParent = modelElement.parentNode; + const shapeParents = shapeElements.map((target) => target.parentNode); + const leafParents = leafElements.map((target) => target.parentNode); + let destroyed = false; + const assertAlive = (): void => { + if (destroyed) fail("target-destroyed", "$", "prepared DOM target is destroyed"); + }; + const shapes = Object.freeze(shapeElements.map( + (target, index) => createElementTarget(target, `$.shapes[${index}]`, assertAlive), + )); + const leaves = Object.freeze(leafElements.map( + (target, index) => createElementTarget(target, `$.leaves[${index}]`, assertAlive), + )); + let requestedModelTransform: string | undefined; + const model = Object.freeze({ + element: modelElement, + writeTransform(value: string): boolean { + assertAlive(); + const next = transform(value, "$.model.transform"); + if (requestedModelTransform === next) return false; + const changed = input.model.writeTransform(next); + if (typeof changed !== "boolean") { + fail("invalid-writer-result", "$.model.writeTransform", "expected a boolean"); + } + requestedModelTransform = next; + return changed; + }, + }); + const assertStableDomIdentity = (): void => { + assertAlive(); + const drifted = modelElement.parentNode !== modelParent + || shapes.some((target, index) => target.element.parentNode !== shapeParents[index]) + || leaves.some((target, index) => target.element.parentNode !== leafParents[index]); + if (drifted) { + fail("identity-drift", "$", "retained DOM identity changed after adoption"); + } + }; + return Object.freeze({ + model, + shapes, + leaves, + get destroyed(): boolean { + return destroyed; + }, + assertStableDomIdentity, + destroy(): void { + destroyed = true; + }, + }); +} diff --git a/packages/morph/src/render/types.ts b/packages/morph/src/render/types.ts index c69e4582..2e5eca24 100644 --- a/packages/morph/src/render/types.ts +++ b/packages/morph/src/render/types.ts @@ -87,3 +87,40 @@ export interface PolyMorphMountedModel { assertStableDomIdentity(): void; destroy(): void; } + +export interface PolyMorphPreparedModelTargetInput { + readonly element: HTMLElement; + writeTransform(transform: string): boolean; +} + +export interface PolyMorphPreparedElementTargetInput { + readonly element: HTMLElement; +} + +export interface PolyMorphPreparedDomTargetInput { + readonly model: PolyMorphPreparedModelTargetInput; + readonly shapes: readonly PolyMorphPreparedElementTargetInput[]; + readonly leaves: readonly PolyMorphPreparedElementTargetInput[]; +} + +export interface PolyMorphPreparedModelTarget { + readonly element: HTMLElement; + writeTransform(transform: string): boolean; +} + +export interface PolyMorphPreparedElementTarget { + readonly element: HTMLElement; + writeTransform(transform: string): boolean; + writeVisibility(visible: boolean): boolean; + writeOpacity(opacity: number): boolean; + writeImagePositionY(position: string): boolean; +} + +export interface PolyMorphPreparedDomTarget { + readonly model: PolyMorphPreparedModelTarget; + readonly shapes: readonly PolyMorphPreparedElementTarget[]; + readonly leaves: readonly PolyMorphPreparedElementTarget[]; + readonly destroyed: boolean; + assertStableDomIdentity(): void; + destroy(): void; +} diff --git a/website/src/content/docs/guides/morph.mdx b/website/src/content/docs/guides/morph.mdx index 8604e342..16d0c614 100644 --- a/website/src/content/docs/guides/morph.mdx +++ b/website/src/content/docs/guides/morph.mdx @@ -67,6 +67,12 @@ Morph does not ship framework wrappers. Prepared playback commits explicitly: apply `sample.update`, then call `runtime.commit(sample)` only after `mounted.apply(...)` succeeds. +If an application already owns a retained DOM graph, +`createPolyMorphPreparedDomTarget` adopts its source-ordered model, shape, and +leaf elements as indexed write targets. Morph deduplicates requested values and +invalidates the writers at destroy; the application retains DOM teardown +ownership. + ## Executable profiles | Profile | Behavior |