diff --git a/README.md b/README.md
index 8d027c79..64051974 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,22 @@
-# pmndrs/text
+# @pmndrs/text
-Unicode-aware text for Three.js and React Three Fiber, with portable font baking and explicit Bitmap, MTSDF, and Slug
-renderers.
+Portable, Unicode-aware text for 3D and canvas rendering engines, with Three.js and React Three Fiber integrations today.
> [!IMPORTANT]
-> `pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable
+> `@pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable
> from this workspace, but the packages are still private and have not been published to npm.
-The engine shapes text once with HarfRust, lays it out as a paragraph, and renders the same positioned glyphs through the
-raster technique selected by the application. Font artifacts can be prepared ahead of time or generated in a Worker when a
-baked asset is unavailable.
+The public core loads fonts, shapes Unicode with HarfRust, lays out paragraphs, resolves paint, and exposes raster lifecycle
+contracts for renderer integrations. A Three.js implementation with React Three Fiber support is available today.
- Native ESM for modern JavaScript runtimes.
-- Framework-neutral `THREE.Group` text objects and a thin React Three Fiber component.
+- Portable shaping, layout, paint, artifact, and raster-technique foundations.
- Unicode 17 bidi, line breaking, grapheme segmentation, complex-script shaping, and horizontal CJK layout.
- Bitmap strikes, MTSDF atlases, and analytic Slug outlines over one shaping and layout result.
- Baked-first delivery with authenticated runtime fallback.
- Retained glyph storage for warm text, layout, paint, font, and raster updates.
- Public raster and baker contracts that third-party packages can implement without importing core internals.
+- A Three.js integration and thin React Three Fiber component.
- WebGPU and WebGL2 product paths exercised by the benchmark and Presentation application.
The [roadmap](docs/roadmap/roadmap.md) records exact milestone status. The v1 renderer and API milestone is closed in the
@@ -38,7 +37,9 @@ pnpm dev
`pnpm dev` starts the benchmark and Presentation app. Mise is the easiest way to install the exact tool versions, but the
same pnpm commands work when compatible versions are already installed.
-## Render text
+## Render text today
+
+The implemented rendering path targets Three.js directly or through React Three Fiber.
### React Three Fiber
@@ -74,7 +75,7 @@ await useFont.preload(uiFont);
### Three.js
-The core `Text` class owns a normal Three.js lifecycle. Its asynchronous generation becomes renderable through ordinary
+The Three.js `Text` class owns a normal engine lifecycle. Its asynchronous generation becomes renderable through ordinary
matrix updates, and warm property changes retain the object while the replacement generation is prepared.
```ts
@@ -143,14 +144,17 @@ Use `pnpm bake --help` for CLI options. The Node API is available from `@pmndrs/
## How the pieces fit
-```text
-source font ──► font baker ──► authenticated core GLB ──► HarfRust shaping
- │ │
- └────────► selected raster baker ──► raster GLB/pages ▼
- paragraph layout
- │
- ▼
- Three.js Text / React Text
+```mermaid
+flowchart LR
+ Font["Font source or baked GLB"] --> Load["defineFont FontLoader + FontRegistry"]
+ Load --> Shape["createRuntimeShaper"]
+ Shape --> Layout["createParagraphEngine ParagraphLayout"]
+ Load --> Raster["RasterRuntime RasterModule"]
+ Layout --> Stage["RasterBatchStage"]
+ Raster --> Stage
+ Stage --> Integration["Renderer integration"]
+ Integration --> Three["Three.js + R3F"]
+ Integration -.-> Other["Other engines"]
```
The core artifact owns shaping data, font metrics, provenance, and the font-local glyph identity space. Raster artifacts own
@@ -161,6 +165,23 @@ Third-party raster implementations use the same public contracts as the built-in
[raster and baker plugin guide](docs/planning/raster-baker-plugin.md); the private
[`@pmndrs/text-glyph-example-raster`](packages/glyph-example-raster) package is the executable external-package proof.
+## Core and renderer integrations
+
+The public APIs below are available today; see the [API contract](docs/planning/api-shapes.md) for the complete surface.
+
+| API | Role |
+| ----------------------------------------------- | -------------------------------------------------------------------------------- |
+| `defineFont`, `FontLoader`, `FontRegistry` | Declare, authenticate, cache, and own font artifacts |
+| `createRuntimeShaper`, `createParagraphEngine` | Produce synchronous measurements and positioned `ParagraphLayout` glyph data |
+| `defineRaster`, `RasterRuntime`, `RasterModule` | Define, load, decode, prepare, and dispose a raster technique |
+| `RasterBatchStage`, `RasterDrawBatch` | Stage complete renderer-owned batches, then commit or abort them transactionally |
+| `Text`, `@pmndrs/text/react` | Use the current Three.js and React Three Fiber integration |
+
+A new renderer consumes `ParagraphLayout`, implements the generic raster resource and batch types, and owns its transforms,
+GPU resources, ordering, publication, and device lifecycle. The [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md)
+tracks the WIP generation boundary, and the [raster plugin guide](docs/planning/raster-baker-plugin.md) shows a working external
+technique.
+
## Repository commands
The contributor-facing command surface is intentionally small:
@@ -197,8 +218,9 @@ The README is the short path into the project. Deeper documentation is organized
- **Look up:** use the [workspace package catalog](docs/packages/index.md),
[renderer capability matrix](docs/planning/renderer-capabilities.md), and
[`PMNDRS_font` extension schemas](docs/planning/extensions/index.md).
-- **Understand:** read the [architecture](docs/planning/architecture.md), [canonical roadmap](docs/roadmap/roadmap.md), and
- [attributed research](RESEARCH.md).
+- **Understand:** read the [architecture](docs/planning/architecture.md),
+ [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md), [canonical roadmap](docs/roadmap/roadmap.md),
+ and [attributed research](RESEARCH.md).
The documentation under [`docs/`](docs/index.md) is also an Open Knowledge Format v0.2 bundle with package-source freshness
checks, provenance, and progressive-disclosure indexes.
@@ -206,7 +228,6 @@ checks, provenance, and progressive-disclosure indexes.
## Current scope
The workspace already implements the v1 shaping, horizontal paragraph, delivery, Three.js/React, and three-raster foundation.
-The roadmap keeps post-v1 work explicit: editorial flow regions, mixed-font fallback, large-coverage CJK raster paging, color
-emoji, expanded effects, and vertical writing.
+The renderer-agnostic core and additional engine integrations remain WIP alongside the roadmap's later layout and raster work.
-`pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized.
+`@pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized.
diff --git a/apps/benchmarks/scripts/build.mts b/apps/benchmarks/scripts/build.mts
index 5d581b3d..d3157db5 100644
--- a/apps/benchmarks/scripts/build.mts
+++ b/apps/benchmarks/scripts/build.mts
@@ -2,7 +2,6 @@ import { buildRuntimePackages, isMainModule, runNodeScript } from './support/com
export async function runBenchmarkBuild(options: { readonly runtimePackagesReady?: boolean } = {}): Promise {
if (!options.runtimePackagesReady) await buildRuntimePackages();
- await runNodeScript('scripts/measure-package-sizes.mts');
await runNodeScript('node_modules/vite/bin/vite.js', ['build']);
await runNodeScript('scripts/check-font-notices.mts');
}
diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts
index 2693f644..e575cfad 100644
--- a/apps/benchmarks/src/benchmark/package-size-budgets.ts
+++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts
@@ -1,7 +1,7 @@
export const packageSizeBudgets = {
'browser-core': {
rawBytes: 341_000,
- minifiedBytes: 258_000,
+ minifiedBytes: 258_500,
gzipBytes: 75_000,
brotliBytes: 57_500,
},
diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts
index 93a426f2..b07b9a14 100644
--- a/apps/benchmarks/src/benchmark/package-sizes.test.ts
+++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts
@@ -68,10 +68,10 @@ describe('independent package-size report', () => {
it('bounds accumulated renderer growth from the pre-coverage baseline', () => {
const coverageGrowth = {
'browser-core': {
- rawBytes: { baseline: 324_269, maximumGrowth: 16_000 },
- minifiedBytes: { baseline: 247_205, maximumGrowth: 10_500 },
- gzipBytes: { baseline: 72_108, maximumGrowth: 2_250 },
- brotliBytes: { baseline: 55_251, maximumGrowth: 1_900 },
+ rawBytes: { baseline: 324_269, maximumGrowth: 17_000 },
+ minifiedBytes: { baseline: 247_205, maximumGrowth: 11_000 },
+ gzipBytes: { baseline: 72_108, maximumGrowth: 2_500 },
+ brotliBytes: { baseline: 55_251, maximumGrowth: 2_100 },
},
'bitmap-baker-js': {
rawBytes: { baseline: 17_478, maximumGrowth: 5_700 },
@@ -86,10 +86,10 @@ describe('independent package-size report', () => {
brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 },
},
'bitmap-runtime-js': {
- rawBytes: { baseline: 361_809, maximumGrowth: 25_500 },
- minifiedBytes: { baseline: 271_005, maximumGrowth: 15_750 },
- gzipBytes: { baseline: 78_673, maximumGrowth: 3_450 },
- brotliBytes: { baseline: 60_857, maximumGrowth: 2_950 },
+ rawBytes: { baseline: 361_809, maximumGrowth: 27_000 },
+ minifiedBytes: { baseline: 271_005, maximumGrowth: 16_500 },
+ gzipBytes: { baseline: 78_673, maximumGrowth: 3_750 },
+ brotliBytes: { baseline: 60_857, maximumGrowth: 3_200 },
},
'mtsdf-baker-wasm': {
rawBytes: { baseline: 534_709, maximumGrowth: 18_500 },
@@ -104,10 +104,10 @@ describe('independent package-size report', () => {
brotliBytes: { baseline: 4_176, maximumGrowth: 800 },
},
'mtsdf-runtime-js': {
- rawBytes: { baseline: 370_255, maximumGrowth: 25_650 },
- minifiedBytes: { baseline: 275_271, maximumGrowth: 15_600 },
- gzipBytes: { baseline: 79_993, maximumGrowth: 3_600 },
- brotliBytes: { baseline: 62_081, maximumGrowth: 3_050 },
+ rawBytes: { baseline: 370_255, maximumGrowth: 27_000 },
+ minifiedBytes: { baseline: 275_271, maximumGrowth: 16_500 },
+ gzipBytes: { baseline: 79_993, maximumGrowth: 3_800 },
+ brotliBytes: { baseline: 62_081, maximumGrowth: 3_300 },
},
} as const;
const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
@@ -126,15 +126,15 @@ describe('independent package-size report', () => {
const retainedCapacityGrowth = {
'bitmap-runtime-js': {
baseline: { rawBytes: 382_060, minifiedBytes: 283_898, gzipBytes: 81_435, brotliBytes: 63_146 },
- maximumGrowth: { rawBytes: 5_200, minifiedBytes: 2_850, gzipBytes: 700, brotliBytes: 650 },
+ maximumGrowth: { rawBytes: 6_500, minifiedBytes: 3_500, gzipBytes: 900, brotliBytes: 850 },
},
'mtsdf-runtime-js': {
baseline: { rawBytes: 389_761, minifiedBytes: 287_629, gzipBytes: 82_721, brotliBytes: 64_286 },
- maximumGrowth: { rawBytes: 6_100, minifiedBytes: 3_200, gzipBytes: 850, brotliBytes: 825 },
+ maximumGrowth: { rawBytes: 7_500, minifiedBytes: 4_000, gzipBytes: 1_050, brotliBytes: 1_050 },
},
'slug-runtime-js': {
baseline: { rawBytes: 390_276, minifiedBytes: 286_600, gzipBytes: 82_730, brotliBytes: 64_271 },
- maximumGrowth: { rawBytes: 9_400, minifiedBytes: 5_050, gzipBytes: 1_300, brotliBytes: 1_275 },
+ maximumGrowth: { rawBytes: 10_750, minifiedBytes: 5_750, gzipBytes: 1_500, brotliBytes: 1_450 },
},
} as const;
const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts
index 429c7969..09a1ecdd 100644
--- a/apps/benchmarks/src/benchmark/scenarios.ts
+++ b/apps/benchmarks/src/benchmark/scenarios.ts
@@ -50,6 +50,7 @@ function externalRasterProofValidation(values: readonly import('./contracts').Be
metrics.retainedObject !== 1 ||
metrics.retainedGeometry !== 1 ||
(metrics.litPixels ?? 0) < 100 ||
+ (metrics.layeringPixels ?? 0) < 100 ||
(metrics.backendWebGpu ?? 0) + (metrics.backendWebGl2 ?? 0) !== 1
) {
throw new Error('External raster proof did not preserve its visible retained draw contract');
diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
index ade2a8ac..fb747351 100644
--- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
+++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts
@@ -28,6 +28,8 @@ interface ExternalRasterResources {
readonly camera: THREE.OrthographicCamera;
readonly text: Text;
readonly font: import('@pmndrs/text').RegisteredFont;
+ readonly orderingGeometry: THREE.PlaneGeometry;
+ readonly orderingMaterial: THREE.MeshBasicNodeMaterial;
readonly retainedObject: THREE.Object3D;
readonly retainedGeometry: THREE.BufferGeometry;
readonly glyphCount: number;
@@ -61,6 +63,8 @@ export function createExternalRasterProofTarget(backend: RendererBackend): Bench
state = { kind: 'empty' };
resources.text.dispose();
resources.font.dispose();
+ resources.orderingGeometry.dispose();
+ resources.orderingMaterial.dispose();
resources.target.dispose();
if (resources.ownedRenderer !== undefined) await disposeConfiguredRenderer(resources.ownedRenderer);
},
@@ -88,6 +92,8 @@ async function createResources(
let target: THREE.RenderTarget | undefined;
let text: Text | undefined;
let font: import('@pmndrs/text').RegisteredFont | undefined;
+ let orderingGeometry: THREE.PlaneGeometry | undefined;
+ let orderingMaterial: THREE.MeshBasicNodeMaterial | undefined;
try {
const physicalWidth = Math.round(WIDTH * dpr);
const physicalHeight = Math.round(HEIGHT * dpr);
@@ -139,8 +145,31 @@ async function createResources(
if (text.layout === undefined)
throw new Error('warm external raster update did not publish during object traversal');
text.position.set(32, -36, 0);
+ text.renderOrder = 600;
+ text.updateMatrixWorld();
+ if (Number(retainedMesh.renderOrder) !== 600)
+ throw new Error('warm external raster did not apply the Text render-order base');
+ text.renderOrder = 0;
+ text.updateMatrixWorld();
+ if (Number(retainedMesh.renderOrder) !== 0)
+ throw new Error('warm external raster did not resynchronize the Text render-order base');
const scene = new THREE.Scene();
- scene.add(text);
+ const coverGroup = new THREE.Group();
+ coverGroup.renderOrder = 100;
+ orderingGeometry = new THREE.PlaneGeometry(WIDTH, HEIGHT);
+ orderingMaterial = new THREE.MeshBasicNodeMaterial({
+ color: 0x7f1734,
+ depthTest: false,
+ depthWrite: false,
+ transparent: true,
+ });
+ const cover = new THREE.Mesh(orderingGeometry, orderingMaterial);
+ cover.position.set(WIDTH / 2, -HEIGHT / 2, 0);
+ coverGroup.add(cover);
+ const textGroup = new THREE.Group();
+ textGroup.renderOrder = 200;
+ textGroup.add(text);
+ scene.add(coverGroup, textGroup);
const camera = new THREE.OrthographicCamera(0, WIDTH, 0, -HEIGHT, 0.1, 10);
camera.position.z = 1;
camera.updateProjectionMatrix();
@@ -154,6 +183,8 @@ async function createResources(
camera,
text,
font,
+ orderingGeometry,
+ orderingMaterial,
retainedObject,
retainedGeometry,
glyphCount: text.layout.glyphIds.length,
@@ -161,6 +192,8 @@ async function createResources(
} catch (error) {
text?.dispose();
font?.dispose();
+ orderingGeometry?.dispose();
+ orderingMaterial?.dispose();
target?.dispose();
if (ownedRenderer !== undefined) await disposeConfiguredRenderer(ownedRenderer);
throw error;
@@ -169,28 +202,56 @@ async function createResources(
async function renderResources(resources: ExternalRasterResources, signal?: AbortSignal): Promise {
signal?.throwIfAborted();
- const bytes = await withRendererStateRestored(resources.renderer, async () => {
+ const { coverBytes, bytes } = await withRendererStateRestored(resources.renderer, async () => {
const { renderer, target } = resources;
const physicalWidth = Math.round(WIDTH * resources.dpr);
const physicalHeight = Math.round(HEIGHT * resources.dpr);
renderer.setRenderTarget(target);
renderer.setClearColor(0x000000, 1);
+ resources.text.visible = false;
+ let coverFrame: Uint8Array;
+ try {
+ renderer.clear();
+ renderer.render(resources.scene, resources.camera);
+ const baselinePixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight);
+ coverFrame = compactRgba8Readback(
+ new Uint8Array(baselinePixels.buffer, baselinePixels.byteOffset, baselinePixels.byteLength),
+ physicalWidth,
+ physicalHeight,
+ resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
+ );
+ } finally {
+ resources.text.visible = true;
+ }
renderer.clear();
renderer.render(resources.scene, resources.camera);
const pixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight);
- return compactRgba8Readback(
- new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
- physicalWidth,
- physicalHeight,
- resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
- );
+ return {
+ coverBytes: coverFrame,
+ bytes: compactRgba8Readback(
+ new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
+ physicalWidth,
+ physicalHeight,
+ resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
+ ),
+ };
});
signal?.throwIfAborted();
let litPixels = 0;
+ let layeringPixels = 0;
for (let offset = 0; offset < bytes.byteLength; offset += 4) {
if (bytes[offset] !== 0 || bytes[offset + 1] !== 0 || bytes[offset + 2] !== 0) litPixels += 1;
+ if (
+ bytes[offset] !== coverBytes[offset] ||
+ bytes[offset + 1] !== coverBytes[offset + 1] ||
+ bytes[offset + 2] !== coverBytes[offset + 2] ||
+ bytes[offset + 3] !== coverBytes[offset + 3]
+ ) {
+ layeringPixels += 1;
+ }
}
if (litPixels < 100) throw new Error('external raster proof produced no visible glyph frames');
+ if (layeringPixels < 100) throw new Error('external raster proof did not honor its caller-owned parent Group order');
const liveObject = exactlyOne(resources.text.children, 'retained external raster draw object');
const liveMesh = exactlyOne(liveObject.children, 'retained external raster mesh');
if (
@@ -210,6 +271,7 @@ async function renderResources(resources: ExternalRasterResources, signal?: Abor
glyphCount: resources.glyphCount,
drawCount: 1,
litPixels,
+ layeringPixels,
retainedObject: 1,
retainedGeometry: 1,
renderTargetGpuBytes: bytes.byteLength,
diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json
index 5f625102..3bfd6424 100644
--- a/apps/benchmarks/src/generated/package-sizes.json
+++ b/apps/benchmarks/src/generated/package-sizes.json
@@ -10,11 +10,11 @@
"label": "Browser core",
"status": "measured",
"format": "javascript",
- "sha256": "312d7e895d738763bd9e757d3c91b732683ea7b9b0198d8fa88a48eebf7239b4",
- "rawBytes": 340105,
- "minifiedBytes": 257533,
- "gzipBytes": 74305,
- "brotliBytes": 57146
+ "sha256": "9ca630720749bd6cfe05890e0c8f532ed2e229c2c36887a8af4d7ca469a70444",
+ "rawBytes": 340812,
+ "minifiedBytes": 258037,
+ "gzipBytes": 74457,
+ "brotliBytes": 57290
},
{
"id": "font-validator-js",
@@ -76,33 +76,33 @@
"label": "Bitmap runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "31a90a5186ce493c878340f41568da707b944957393106f49269fe801478ad85",
- "rawBytes": 387160,
- "minifiedBytes": 286628,
- "gzipBytes": 82047,
- "brotliBytes": 63670
+ "sha256": "db2272cc995d5561bf8829897f55668502295f80beae679088b09557e22c1ea3",
+ "rawBytes": 388324,
+ "minifiedBytes": 287345,
+ "gzipBytes": 82263,
+ "brotliBytes": 63964
},
{
"id": "mtsdf-runtime-js",
"label": "MTSDF runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "87e4ff302ab05cef9154a56e16016c49f9106968c64c88fbebd6ec5dc978e05e",
- "rawBytes": 395744,
- "minifiedBytes": 290728,
- "gzipBytes": 83485,
- "brotliBytes": 65007
+ "sha256": "3399c72d46bf210d813343950791b4d5114d37f0410f261d2c72fdd0aa7b2a9f",
+ "rawBytes": 397032,
+ "minifiedBytes": 291509,
+ "gzipBytes": 83717,
+ "brotliBytes": 65318
},
{
"id": "slug-runtime-js",
"label": "Slug runtime JS graph",
"status": "measured",
"format": "javascript",
- "sha256": "3a199b5aa44a608e355dcb01ae29a8fa6e0114b02868672a6fc93fe0a6599c65",
- "rawBytes": 399530,
- "minifiedBytes": 291527,
- "gzipBytes": 83955,
- "brotliBytes": 65481
+ "sha256": "f6ccace1a011dec2582d874dc7bcb3fd070f4ad04de45f5ae1c507c6e4156c77",
+ "rawBytes": 400767,
+ "minifiedBytes": 292301,
+ "gzipBytes": 84209,
+ "brotliBytes": 65667
},
{
"id": "bitmap-baker-wasm",
diff --git a/apps/benchmarks/vitexec/external-raster-proof.probe.ts b/apps/benchmarks/vitexec/external-raster-proof.probe.ts
index 0a54c878..6f7b314a 100644
--- a/apps/benchmarks/vitexec/external-raster-proof.probe.ts
+++ b/apps/benchmarks/vitexec/external-raster-proof.probe.ts
@@ -68,7 +68,8 @@ for (const [targetId, backendMetric] of [
measurement.metrics.drawCount !== 1 ||
measurement.metrics.retainedObject !== 1 ||
measurement.metrics.retainedGeometry !== 1 ||
- (measurement.metrics.litPixels ?? 0) < 100,
+ (measurement.metrics.litPixels ?? 0) < 100 ||
+ (measurement.metrics.layeringPixels ?? 0) < 100,
)
) {
throw new Error(`${targetId} did not preserve the public external raster contract`);
diff --git a/docs/log.md b/docs/log.md
index f36e816b..f4fa1e44 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,7 +1,13 @@
# pmndrs/text documentation update log
+## 2026-08-05
+
+- **Renderer portability orientation** — Reframed Three.js/TSL and React Three Fiber as the first integrations over portable text foundations, added a compact public-core-to-adapter graph, and marked the serialized renderer-agnostic core plan as WIP.
+
## 2026-08-04
+- **Renderer-agnostic engine boundary planning** — Drafted the proposed next additive milestone around three independent axes: canvas/game-engine host, GPU-authoring layer, and application binding. The plan keeps Bitmap, MSDF, and Slug contracts plus lazy bakers portable; extracts the Three-owned text-generation state machine without accepting its final name; preserves Three.js + TSL as the dual-backend baseline; and requires separate Three.js + TypeGPU and non-Three engine proofs before stabilizing package exports or an adapter API. The canonical roadmap order remains unchanged until maintainer acceptance.
+- **Text layering contract** — Made framework-neutral `Text` a composite `Object3D` so it honors caller-owned parent Group ordering, while `Text.renderOrder` becomes the base for each generated drawable's raster-local order. Bitmap, MTSDF, Slug, and the external raster proof implement the public base-order method and use neutral `Object3D` batch roots; the adapter rejects nested raster Groups, and focused tests cover cold publication, changes without reshaping, retained updates, React Object3D props, and multi-font spans. Against the parent stack layer, browser core grows by 707 raw / 504 minified / 152 gzip / 144 Brotli bytes; Bitmap, MTSDF, and Slug runtime closures grow by 1,164/717/216/294, 1,288/781/232/311, and 1,237/774/254/186 bytes respectively. The reviewed absolute and cumulative JavaScript ceilings advance only where those production paths grew. Ordinary builds consume the checked-in canonical size record instead of rewriting it with host-specific measurements; the explicit size-generation workflow remains its sole writer, while tests measure the current host read-only against the reviewed ceilings.
- **Human-facing repository orientation** — Replaced the root README's stale planned-milestone narrative with a concise pre-release landing page for the implemented workspace. It now declares active development toward a public v1 API without implying npm availability, provides runnable local setup plus canonical React, Three.js, raster-selection, and bake examples, explains the shaping-to-rendering pipeline, and routes readers by learning, task, reference, and explanation needs. Corrected the font-baker Wasm URL example and moved its retired package commands to the source-indexed workflow surface.
- **Contributor workflow cleanup** — Limited the root command surface to `bake`, `dev`, `build`, `test`, `check`, and `scripts`; library manifests now expose only build, test, and check, while the benchmark app additionally exposes dev. Replaced duplicated command-family routers with one source-metadata index that validates and describes specialized fixture, release-evidence, fuzz, profiling, capture, and hardware-browser workflows. Removed closed-milestone probes, rejected experiment runners, and implementation-shaped benchmark tests superseded by public package integration, headless product, sequential Presentation, timed-demo, and exclusive finite-job recovery gates. Agent guidance now requires `pnpm scripts list/show` before inventing a maintenance command.
diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md
index 22b52157..69207216 100644
--- a/docs/packages/benchmarks.md
+++ b/docs/packages/benchmarks.md
@@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur
resource: ../../apps/benchmarks
workspace_package: '@pmndrs/text-benchmarks'
documentation_type: reference
-source_digest: 'sha256:0f186806b091836dea9ff4bcaa7a59d8980a59f87af8ceb34ee46c04b188836c'
+source_digest: 'sha256:37ba2404b7731d266f117f25353a23879d739b0ec3aa4f84f6d17d47a58a0297'
tags: [package, benchmarks, react, vite, product-e2e]
sources:
- id: manifest
@@ -178,7 +178,7 @@ sources:
title: Realtime comparison product probe
generated:
by: openai-codex/gpt-5.6
- at: '2026-08-04T17:37:17Z'
+ at: '2026-08-04T20:03:01Z'
---
# Package reference: `@pmndrs/text-benchmarks`
@@ -202,7 +202,7 @@ glyphs or paints.
Font delivery is an explicit benchmark axis. **Baked asset** exercises the normal sibling asset, while **Runtime bake** passes `{ source, baked: null }`, downloads the source font, builds the core font in the serial core-baker Worker, then builds the selected Bitmap or MSDF raster in its serial lazy Worker. The inspector distinguishes the always-loaded runtime/shaper graph from the conditional core and raster baker host, Worker, and Wasm graphs; it reports source download bytes, generated core/raster CPU bytes, bake durations, and atlas GPU memory. The runtime-fallback conformance workload renders both delivery paths through the same public pipeline and requires an exact RGBA frame match. Canonical Inter matched with zero differing bytes for Bitmap and MSDF on the admitted WebGPU product probe; the observed cold MSDF raster bake was roughly 114 seconds on this host and remains an observation, not a portability threshold.
-The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep]
+The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. An ordinary build consumes the checked-in canonical package-size record without rewriting it for the current host. `release:size:generate` is the sole writer, while the test gate measures the current host read-only and enforces the reviewed absolute and cumulative ceilings. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep]
`pnpm scripts run benchmark:demo` exercises the complete 60-second timed sequence through a focused control on WebGPU and forced WebGL. Off-axis / 3D and Icon Grid each receive two seconds before Paint & Effects begins at second four; the more visual Zoom Text and returning Icon Grid scenes receive longer holds than Dynamic Layout. Advanced Shaping resets to CJK and reveals one complete five-case cycle at 180 grapheme units per second. Playing case transitions begin the next script at its first grapheme; a font-changing handoff deliberately blanks the live line until that generation commits instead of showing mismatched old-script state. Zoom Text pre-shapes all 16 fixed-Inter, language-tagged words during cold scene preparation and retains one node per word; animation performs only scale, opacity, and visibility changes, so it continues its normal word cycle without an animation-time readiness boundary and cuts after three complete default-speed drops. Text Ladder receives the derived 7.2 seconds required for its default-speed vertical travel and 1024 px marquee to pass completely through the left edge before the nine-second Icon Grid return. A final 8.016-second Off-axis / 3D scene supplies the closing frame. The probe requires window-capture Space handling, exact workload defaults after preload, advancing telemetry, a retained canvas, exactly one renderer, both Icon Grid entries, the configured backend throughout, and the final Off-axis / 3D scene.
@@ -220,6 +220,10 @@ Every live benchmark identity resolves through one typed catalog under `apps/ben
The root `app.tsx` owns only runner detection, shell Suspense, and URL route selection. Both route branches render the same `routes/harness-route.tsx` component type, preserving one runtime-world identity while `controllers/harness-controller.tsx` owns URL revisions, post-preload transitions, presentation playback, shortcuts, and execution state. Persistent renderer provisioning and exclusive conformance-action adaptation live in `surfaces/harness/persistent-layout.tsx`; Benchmark/Conformance scene composition lives in `surfaces/harness/scene.tsx`; Main and Presentation chrome remains in `components/harness-layout.tsx`; and runtime control binding remains in `components/runtime-controls.tsx`. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers live under `surfaces/benchmark`, keeping their host lease, warm update queue, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane uses the public `FontLoader`, `FontRegistry`, raster request, and `@pmndrs/text/runtime-bake` entrypoint. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, runtime progress and delivery metrics, and bounded default registries; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph.
+The external raster product proof renders a competing transparent cover and public `Text` under different parent Groups on
+WebGPU and WebGL2. Framebuffer differences prove that the composite Text and neutral plugin batch preserve the caller-owned
+primary group order through actual Three.js sorting.
+
Timed playback compares each frame with the latest requested location rather than the last committed scene, so an in-flight preload receives exactly one request and cannot be superseded by a duplicate transition that skips workload-default initialization. Presentation captures Space at the window capture boundary to start or stop timed playback even while a button, switch, slider, select, or combobox owns focus; matching key-up activation is suppressed, while inputs, textareas, and editable text retain ordinary space entry. Arrow navigation remains disabled on interactive controls.
Icon Grid auto-pan integrates a bounded exponential average of observed frame deltas so a delayed display frame does not become a visually abrupt catch-up jump. This affects motion only: the virtual window still traverses the complete 1,402-glyph catalog, retains its overscanned pool, and requests content reassignment after crossing a complete cell pitch. Its second timed appearance, after Text Ladder, starts at a different catalog position and reverses both axes. Content changes use the generic `Text` update contract and retained raster capacity; catalog glyph/label strings, assignment epochs, recyclable-entry lists, visible-entry metrics, geometry deduplication, font totals, and bitmap atlas-page reports retain caller-owned storage instead of allocating on every frame or recycle. Pool growth remains genuinely cold and detached until ready, then publishes size, view position, and assignments in one continuation. A 20-second WebGPU/Bitmap trace traversed 660 recycled glyph assignments across 31 coherent windows at 60.05 average FPS, 18.60 ms p95, 18.64 ms maximum, and zero frames over 20 ms; 20 minor and five major GC events each remained below 2.6 ms and produced no visible cadence miss. Renderer-wide batching across separate `Text` objects remains outside this milestone.
@@ -256,7 +260,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa
The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle.
-The current Darwin arm64 record reports a 257,582 minified / 74,316 gzip / 57,070 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The current lifecycle-published Slug runtime measures 286,600 minified / 82,730 gzip / 64,271 Brotli bytes. Its baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic.
+The current Darwin arm64 record reports a 258,037 minified / 74,457 gzip / 57,290 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The render-order-capable Bitmap, MTSDF, and Slug runtime closures now measure 287,345 / 82,263 / 63,964, 291,509 / 83,717 / 65,318, and 292,301 / 84,209 / 65,667 minified/gzip/Brotli bytes. Slug's baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic.
The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry.
diff --git a/docs/packages/glyph-example-raster.md b/docs/packages/glyph-example-raster.md
index cd539ebf..c0f3b330 100644
--- a/docs/packages/glyph-example-raster.md
+++ b/docs/packages/glyph-example-raster.md
@@ -5,7 +5,7 @@ description: Proves the published raster and baker extension boundary with a pri
resource: ../../packages/glyph-example-raster
workspace_package: '@pmndrs/text-glyph-example-raster'
documentation_type: reference
-source_digest: 'sha256:1b25dd5a8c679e241da5d73402e42c3441087587efffc9a25799d69dc5f229a0'
+source_digest: 'sha256:e7d18c2c53b9b5090c4f81fc3e20ed9be3d7b048b9a84db104830d6ffd33c6fb'
tags: [package, raster, extension-proof, threejs, tsl]
sources:
- id: manifest
@@ -28,7 +28,7 @@ sources:
title: Dual-backend product rendering probe
generated:
by: openai-codex/gpt-5.6
- at: '2026-08-04T17:42:34Z'
+ at: '2026-08-04T18:59:39Z'
---
# Package reference: `@pmndrs/text-glyph-example-raster`
@@ -39,8 +39,8 @@ This private workspace package is a consumer proof, not a fourth recommended pro
`@pmndrs/text` entry points and its own pinned Three.js dependency. It owns the literal `glyphExample` kind, companion
extension and descriptor, deterministic baker, standalone-valid GLB framing, embedded or authenticated external RGBA glyph
records, decoder validation, runtime baker, TSL material, retained instance storage, dirty upload policy, overflow replacement,
-abort behavior, and disposal. A source boundary test rejects imports from core internals or the three first-party raster and
-baker subpaths.
+paragraph/local-run render-order inheritance, abort behavior, and disposal. A source boundary test rejects imports from
+core internals or the three first-party raster and baker subpaths.
The technique makes the proof observable by assigning each source-local glyph ID a deterministic color and drawing a framed
em-relative diagnostic cell at the position produced by core shaping and paragraph layout. Its visual output is deliberately
@@ -64,11 +64,14 @@ clear, viewport, scissor, and scissor-test state, and never creates or disposes
## Boundary findings
-The proof found and closed two public integration defects. First, portable `RasterDrawBatch` correctly promised only disposal
+The proof found and closed three public integration defects. First, portable `RasterDrawBatch` correctly promised only disposal
while Three-backed `Text` silently required an `Object3D`. Core now publishes renderer-neutral
`RasterObjectDrawBatch