diff --git a/packages/core/scripts/test-hyperframe-runtime-contract.ts b/packages/core/scripts/test-hyperframe-runtime-contract.ts
index 699dafb37..d10cf8080 100644
--- a/packages/core/scripts/test-hyperframe-runtime-contract.ts
+++ b/packages/core/scripts/test-hyperframe-runtime-contract.ts
@@ -27,6 +27,66 @@ for (const snippet of requiredSnippets) {
assert(runtimeSource.includes(snippet), `Runtime contract snippet missing: ${snippet}`);
}
+// Snippet checks cannot prove what `window.__hyperframes` ends up carrying.
+// A name can reach the bundle through an unrelated import: `fitTextFontSize.ts`
+// imports `prepare`/`layout` from `@chenglou/pretext`, so grepping for
+// "pretext" stays green even if the attachment in entry.ts is deleted. Run the
+// runtime and read the object back instead.
+const { JSDOM } = await import("jsdom");
+const dom = new JSDOM("
", {
+ runScripts: "outside-only",
+});
+// The runtime's DOMContentLoaded path calls requestAnimationFrame. jsdom only
+// provides it under `pretendToBeVisual`, which also starts a frame loop that
+// keeps the process alive, so stub it instead. The stub never fires: we are
+// asserting what the runtime attaches at evaluation time, not what it animates.
+Object.defineProperty(dom.window, "requestAnimationFrame", {
+ value: () => 0,
+ configurable: true,
+});
+Object.defineProperty(dom.window, "cancelAnimationFrame", {
+ value: () => {},
+ configurable: true,
+});
+dom.window.eval(runtimeSource);
+
+const hyperframes = (dom.window as unknown as { __hyperframes?: Record })
+ .__hyperframes;
+assert(hyperframes, "Runtime did not attach window.__hyperframes");
+
+// Each of these is an API the agent-facing docs tell composition authors to
+// call. If one stops being attached, every composition written against the
+// docs throws at render time, which no other check would catch.
+const requiredHelpers = ["fitTextFontSize", "getVariables"];
+for (const helper of requiredHelpers) {
+ assert(
+ typeof hyperframes[helper] === "function",
+ `window.__hyperframes.${helper} is not attached`,
+ );
+}
+
+const pretext = hyperframes.pretext as Record | undefined;
+assert(pretext, "window.__hyperframes.pretext is not attached");
+const requiredPretextFns = [
+ "prepare",
+ "layout",
+ "prepareWithSegments",
+ "measureLineStats",
+ "measureNaturalWidth",
+];
+for (const fn of requiredPretextFns) {
+ assert(typeof pretext[fn] === "function", `window.__hyperframes.pretext.${fn} is not attached`);
+}
+
+// clearCache/setLocale mutate state shared across compositions. Keeping them
+// off the surface is a deliberate determinism choice, so assert it holds.
+for (const forbidden of ["clearCache", "setLocale"]) {
+ assert(
+ !(forbidden in pretext),
+ `window.__hyperframes.pretext.${forbidden} must not be exposed (mutates shared state)`,
+ );
+}
+
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
const manifestPath = resolve(scriptDir, "../dist/hyperframe.manifest.json");
try {
diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts
index 5851da238..022709520 100644
--- a/packages/core/src/runtime/entry.ts
+++ b/packages/core/src/runtime/entry.ts
@@ -1,6 +1,7 @@
import { initSandboxRuntimeModular } from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { fitTextFontSize } from "../text/fitTextFontSize";
+import { pretext } from "../text/pretext";
import { getVariables } from "./getVariables";
type HyperframeWindow = Window & {
@@ -8,6 +9,7 @@ type HyperframeWindow = Window & {
__hyperframes?: {
fitTextFontSize: typeof fitTextFontSize;
getVariables: typeof getVariables;
+ pretext: typeof pretext;
};
};
@@ -26,6 +28,7 @@ installAuthoredOpacityCapture();
(window as HyperframeWindow).__hyperframes = {
fitTextFontSize,
getVariables,
+ pretext,
};
function bootstrapHyperframeRuntime(): void {
diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts
index 72ec26af2..e64c346fd 100644
--- a/packages/core/src/text/index.ts
+++ b/packages/core/src/text/index.ts
@@ -1,2 +1,3 @@
export { fitTextFontSize } from "./fitTextFontSize.js";
export type { FitTextOptions, FitTextResult } from "./fitTextFontSize.js";
+export { pretext } from "./pretext.js";
diff --git a/packages/core/src/text/pretext.test.ts b/packages/core/src/text/pretext.test.ts
new file mode 100644
index 000000000..64fc03995
--- /dev/null
+++ b/packages/core/src/text/pretext.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+
+import { pretext } from "./pretext.js";
+
+// Behavior is not asserted here on purpose. `prepare` measures fonts through a
+// canvas, which Node does not have, so the real measuring is covered by the
+// browser check in `packages/cli` rather than mocked into meaninglessness here.
+// What this file guards is the shape of the published surface.
+describe("pretext runtime surface", () => {
+ it("exposes the documented prepare and layout functions", () => {
+ // The skill file agents are required to read documents
+ // `window.__hyperframes.pretext.prepare(...)` / `.layout(...)`. If either
+ // name disappears, every composition written against that doc throws.
+ expect(typeof pretext.prepare).toBe("function");
+ expect(typeof pretext.layout).toBe("function");
+ });
+
+ it("exposes the width helpers the documented shrinkwrap case needs", () => {
+ // `layout` returns only { lineCount, height }. Width has to come from
+ // these, so without them "shrinkwrap containers" is not actually doable.
+ expect(typeof pretext.prepareWithSegments).toBe("function");
+ expect(typeof pretext.measureLineStats).toBe("function");
+ expect(typeof pretext.measureNaturalWidth).toBe("function");
+ });
+
+ it("does not expose the globally-stateful functions", () => {
+ // clearCache/setLocale mutate state shared across compositions, which
+ // would make a render depend on what ran before it.
+ expect("clearCache" in pretext).toBe(false);
+ expect("setLocale" in pretext).toBe(false);
+ });
+});
diff --git a/packages/core/src/text/pretext.ts b/packages/core/src/text/pretext.ts
new file mode 100644
index 000000000..655e3de00
--- /dev/null
+++ b/packages/core/src/text/pretext.ts
@@ -0,0 +1,51 @@
+import {
+ layout,
+ measureLineStats,
+ measureNaturalWidth,
+ prepare,
+ prepareWithSegments,
+} from "@chenglou/pretext";
+
+/**
+ * The text measurement surface exposed to compositions as
+ * `window.__hyperframes.pretext`.
+ *
+ * Measuring text by writing it into the DOM and reading it back forces a
+ * reflow, which is both slow per frame and a determinism risk (the value
+ * depends on when you read it). These measure off a canvas instead, so a
+ * composition can size a container, pick a font size, or decide a line break
+ * at any frame without disturbing layout. `fitTextFontSize` is itself built on
+ * `prepare` + `layout`.
+ *
+ * Note the split, because it decides where you call these: `prepare` (and
+ * `prepareWithSegments`) does the real font measurement and needs a canvas, so
+ * it only works in a browser, not in Node. Everything downstream of a prepared
+ * string is arithmetic and is cheap enough to run per frame.
+ *
+ * Deliberately omitted from this surface:
+ * - `clearCache` and `setLocale` mutate process-global state. A composition
+ * calling either would change how *other* compositions measure, which breaks
+ * the guarantee that the same file renders the same video every time.
+ * Withholding `clearCache` does not strand memory: the cache is keyed by
+ * (segment, font) where segments come from `Intl.Segmenter` at word
+ * granularity, so it grows with the number of distinct *words* a composition
+ * renders, not with frames. A counter or typewriter re-measuring every frame
+ * reuses the same entries. Only genuinely new words allocate, which bounds it
+ * at a composition's vocabulary.
+ * - The incremental cursor API (`layoutNextLine`, `walkLineRanges`, and
+ * friends) has no caller yet. Add it when something needs it.
+ *
+ * @see packages/core/src/runtime/entry.ts for where this is attached.
+ */
+export const pretext = {
+ /** Measure a string for a CSS font (e.g. `"700 48px Inter"`). */
+ prepare,
+ /** Line count and total height for a prepared string at a given width. */
+ layout,
+ /** Like `prepare`, but retains segments so widths can be measured. */
+ prepareWithSegments,
+ /** Line count plus `maxLineWidth` — the widest rendered line. */
+ measureLineStats,
+ /** Width the string would occupy on a single unwrapped line. */
+ measureNaturalWidth,
+} as const;
diff --git a/skills-manifest.json b/skills-manifest.json
index 2f6e534ed..3041dcd80 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
- "hash": "e9daaccaed05b8b4",
+ "hash": "4ca81d4092effc62",
"files": 19
},
"hyperframes-creative": {
diff --git a/skills/hyperframes-core/references/determinism-rules.md b/skills/hyperframes-core/references/determinism-rules.md
index 44aef9835..ced5eb8c4 100644
--- a/skills/hyperframes-core/references/determinism-rules.md
+++ b/skills/hyperframes-core/references/determinism-rules.md
@@ -56,7 +56,10 @@ Build the visible end-state in static HTML and CSS first, then animate from/to t
- Use `position: absolute` for layers and decorative elements, not as the default content-layout strategy.
- Prefer transforms and opacity for animation.
- Keep text inside its intended container. For dynamic text, use `max-width`, wrapping, or `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })`.
-- For text measurement without DOM reflow, use `window.__hyperframes.pretext`: `pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)`. Pure arithmetic, ~0.0002 ms per call — safe for per-frame text reflow, shrinkwrap containers, and computing layout before render. `fitTextFontSize` is built on it.
+- For text measurement without DOM reflow, use `window.__hyperframes.pretext`. Measure off a canvas instead of writing into the page and reading it back, so nothing reflows: `pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)` → `{ lineCount, height }`. `prepare` does the font measurement; everything downstream of a prepared string is arithmetic and cheap enough to run per frame. `fitTextFontSize` is built on it.
+ - `layout` gives you height, not width. To size a container to its text (shrinkwrap), use `pretext.prepareWithSegments(text, font)` and then `pretext.measureNaturalWidth(prepared)` for the single-line width, or `pretext.measureLineStats(prepared, maxWidth)` for `{ lineCount, maxLineWidth }`.
+ - `font` is a CSS font shorthand string, e.g. `"700 90px Inter"`.
+ - `clearCache` and `setLocale` are deliberately not exposed: they mutate state shared across compositions, which would make a render depend on what ran before it.
- **Do not** use `
` in body text. Forced breaks ignore the actual rendered font width and produce an extra break when the line already wraps naturally, causing overlap. Let text wrap via `max-width`. Exception: short display titles where each word is deliberately on its own line.
- **Transformed elements must be block-level + sized.** `transform`/`scaleX`/`scaleY` is a no-op on an inline ``, and scaling an auto-width (0px) element shows nothing → invisible bars/fills. Give them `display: block`/`inline-block`/flex-item **and** a real `width`/`height` (e.g. `width: 100%` inside a sized parent). _(Silent — automated gates may miss it.)_
- **Absolutely-positioned decoratives that pulse or overshoot** (`yoyo` scale, `back.out`) need clearance at their **peak** size and must not straddle an `overflow: hidden` edge — else they overlap a neighbor or get clipped. Position for the largest frame, not the resting one. _(silent.)_