feat(core): expose pretext text measurement on window.__hyperframes (#3302)

## What

Exposes the `pretext` text-measurement API on `window.__hyperframes`, so the API our agent-facing docs already describe actually exists.

Adds `pretext.prepare`, `.layout`, `.prepareWithSegments`, `.measureLineStats`, `.measureNaturalWidth`.

## Why

`skills/hyperframes-core/references/determinism-rules.md` is required reading for any agent authoring a composition. Line 59 tells them to call `window.__hyperframes.pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)` for text measurement without a DOM reflow.

That object did not exist. The runtime exposed exactly `fitTextFontSize` and `getVariables`. Any composition following the documented recipe threw at runtime.

Deleting the doc line was the smaller change, but reflow-free measurement is genuinely the right tool for sizing text per frame, and `fitTextFontSize` is already built on it. Making the docs true is the better fix.

## How

- New `packages/core/src/text/pretext.ts` assembles the exposed surface in one place, with the include/exclude rationale next to it.
- `entry.ts` attaches it alongside the existing helpers. Sub-compositions inherit it for free: the scoping shim builds its scoped variant with `Object.assign({}, base, { getVariables })`, so anything added to the base object is carried through.

Two deliberate decisions:

**Wider than the doc named.** `layout()` returns only `{ lineCount, height }`. The doc's own "shrinkwrap containers" use case needs a width, which is impossible with just `prepare` + `layout`. `measureNaturalWidth` and `measureLineStats` make that claim achievable; `prepareWithSegments` is their required input.

**`clearCache` and `setLocale` withheld.** Both mutate state shared across compositions. Exposing them would let one composition change how a later one measures, making a render depend on what ran before it.

**Doc correction.** The reference called this "pure arithmetic, ~0.0002 ms per call". Not quite: `prepare` measures fonts through a canvas and throws outside a browser. Only the steps after a prepared string are arithmetic. Reworded, and documented the width helpers and the omissions.

## Trade-off

The runtime bundle grows **4,903 bytes (+1.30%)**, from 377,865 to 382,768. That ships inline in every composition. Measured by building the artifact with and without the change.

## Test plan

- [x] Unit tests added/updated
- [x] Manual testing performed
- [x] Documentation updated (if applicable)

`packages/core/src/text/pretext.test.ts` guards the shape of the published surface: the two documented names exist, the width helpers exist, and the two stateful functions are absent. Behaviour is deliberately not asserted there. `prepare` needs a canvas, and mocking it (as `fitTextFontSize.test.ts` must) would assert nothing real.

Real behaviour was verified by rendering a composition that calls the documented API:

```
lines=2  height=216  naturalWidth=1785
```

Self-consistent: natural width 1785 exceeds the 1600 container so it wraps to 2 lines, and 2 x 108 line-height is exactly the reported 216. The frame was inspected visually.

Also run:
- `packages/core` full suite from the package root: **903 passed, 46 files**
- `tsc --noEmit` and `tsc --noEmit -p tsconfig.runtime.json`: clean
- `oxlint` / `oxfmt`: clean

## Follow-ups (not in this PR)

An audit of the wider attribute surface found several more doc/runtime mismatches, including `data-gpu-mode` documented as an HTML attribute when it is a config field, and `data-no-timeline` being real, load-bearing, and absent from the table agents read. Those are separate changes.
This commit is contained in:
Miguel Ángel
2026-08-17 16:42:27 -04:00
committed by GitHub
parent 5e36f7ac54
commit 0285a711e9
7 changed files with 152 additions and 2 deletions
+1
View File
@@ -1,2 +1,3 @@
export { fitTextFontSize } from "./fitTextFontSize.js";
export type { FitTextOptions, FitTextResult } from "./fitTextFontSize.js";
export { pretext } from "./pretext.js";
+32
View File
@@ -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);
});
});
+51
View File
@@ -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;