@agentfiber/render-core
v0.2.0
Published
Renderer-neutral contracts and lifecycle interfaces for retained adaptive UI backends.
Readme
@agentfiber/render-core — the TV renderer contract
Stage 0 of docs/m2-render-core-plan.md.
Designed as @agentfiber/render-core; it lives here first for iteration speed
(Stage 4 graduates it).
A types-first package: the public renderer API of an agentic-first, voice-enabled TV framework. It is TypeScript interfaces, three pure helpers, and a conformance suite. It imports no renderer and emits essentially no runtime code.
The platform frame
The pitch this API has to carry is DX and familiarity over the incumbent: build TV UIs with React + TanStack + JSX rather than a bespoke scene DSL, with agent-friendly typed contracts and performance that was measured on the hardware people actually ship on. This package is the renderer half of that promise — the layer that lets a TV primitives library (rails, tiles, scroll areas, windowing) be written once against a contract instead of against a general-purpose game renderer's defaults.
The contract is not speculative. Every clause is either a shape the shipping app already needs or a rule a measurement paid for, and the JSDoc cites which. The two authorities:
docs/research/renderer-core-analysis.md— §3 the node census, §8 the enumerated replacement contract, §9B the seam construction rules, §10 the staging.docs/perf/m0-results-2026-08-01.md— the on-box campaign: boot TTI, the mount-churn cliff, the render-group attribution null, the idle invariant on glass, the soak.
Target device
BCM7271-class set-top box on WPE WebKit: concurrent GC off (every allocation burst becomes a later stop-the-world pause), ~33 ms frame budget, fixed 1920×1080 stage, low GPU memory, WebGL2 (measured on 11/11 box boots — an earlier documented inference said WebGL1 and was wrong). Desktop hides every class of problem this device has. When a rule here looks over-strict, it is because a workstation graded the losing arm better.
The zero-wrapper rule
Compile-time, zero-wrapper, type-level substitution only. Never runtime handle-wrapping on hot paths.
This is the seam's cost bound, and it is the one rule that makes the abstraction worth having. A wrapper object per node is a new allocation class under GC-off silicon — precisely the failure mode the platform exists to avoid. Concretely:
- Node handles are structural interfaces, not wrappers.
node.x = 12is a direct field write on the backend's own object. There is nothing to construct, nothing to allocate, no indirection.src/conformance.test.tsproves pixi's classes satisfy the handle types as they are. - Backend selection is module substitution, resolved by the bundler —
the pattern
@agentfiber/render-primitivesalready uses, where the whole coupling isimport type { Container }and the emitted module contains no renderer import. RenderBackendis a boot-cadence aggregate. It exists so a backend can be type-checked as a whole and so tests can inject fakes. No hot path routes through it.
The corollary for reviewers: any PR that adds a wrap(node), a proxy, a
Map<BackendNode, ContractNode>, or a per-frame .get() on this seam is
wrong by construction, however clean it reads.
Backend #1 is pixi
Pixi v8 is backend #1 and is expected to stay backend #1 for the foreseeable future. That is a finding, not a placeholder.
The weekend campaign ran the attribution rung that was supposed to turn a measured deficit into a statement about pixi, and it exonerated pixi. All three levers, same box, same instruments:
| lever | what it removes | effect on hitches |
|---|---|---|
| per-rail render groups | the renderer's group-wide repack + re-record | ~0% |
| wide retention (?overscan=24) | mount/unmount churn (decode, upload, allocation) | −31 to −60% |
| near-empty route | all content | +138% fps |
The cost on this hardware is mounting, decoding and uploading content — not how a renderer re-records a scene it already holds. So this package exists because a platform needs a public renderer API, not because a renderer swap is expected to buy frame time. Anyone proposing a second backend should read §10's execution logs first and state which frame (app-scoped or platform R&D) they are arguing in.
What the seam does buy, stated honestly (§9B): it concentrates the
backend re-verify surface into one package. The current backend's
scene-mutation implementation stays the same monkey-wrap underneath until
a second backend exists — but it stops being app code, and the
assumptions it makes about upstream become a versioned, diffable list
(BackendMeta.reverifyOnUpgrade).
What the contract covers
| Module | Clause |
|---|---|
| nodes.ts | The six retained node types, with props (reconciler-diffed, data-change cadence) kept separate from handles (imperative, keypress/frame cadence) |
| scene.ts | Scene-mutation notification as a first-class API — the thing whose absence forced a monkey-patch; render-group isolation with the funnel-visibility obligation; the render-on-demand scheduler |
| host.ts | One host, externally-driven clock (update(now) / render()), post-render hook, the never-throw render contract, boot marks |
| textures.ts | Load / UV-crop / deterministic crop-view release / paced upload / decoded-source release / byte-budgeted retention, plus isRenderableTexture and coverFrame |
| text.ts | The two text tiers, the atlas base-size invariant, the font boot gate, text-texture counters |
| effects.ts | Render-to-texture, custom shader quads, gradient fills |
| observability.ts | The pull-only snapshot surface |
| backend.ts | The backend aggregate, declared capabilities, and the upgrade re-verify contract |
Invariants a backend inherits
- Zero renders while idle. The host never schedules; the scheduler does. Certified on glass — 0 unique frames over 42 s captures.
render()must not throw. A self-re-arming frame loop loses the whole session to one exception. Contain, report, keep drawing.- Scene-mutation notification must be complete, and must be silent on no-op writes. Incompleteness produces a UI that intermittently fails to redraw; over-notification turns every converged animation into a permanent 60 Hz loop.
- Promoting a render group must keep the subtree observable. A nested group notifies itself, not the root. Discovered on glass.
visible: falsemust skip traversal, not just paint. There is no occlusion culling anywhere in this stack.- Atlas text renders at its authored size or not at all. Measured crispness at 1:1 is ~11× the off-ratio score.
- Never lazily re-upload a released decoded source. Bound residency with the byte budget instead; recover a context loss by purging and refetching.
- Retention beats recycling. Do not add a slot-recycling tier — content-swap still invalidates raster and re-decodes, and it lost to plain retention on the sibling stack.
Status
P1 graduation is complete on this TVKit branch. The contract is at 0.2.0, the Pixi reference backend and renderer-neutral primitives are owned here, and the host/lifecycle split has passed the frozen-source gauntlet. Pixi remains the production reference backend. P2 stacks on this head; the upstream Tevee source commit remains the merge gate.
A retained React/WebGL2 Micro benchmark proof now supplies promising F1 evidence for a second backend, but it is neither a certified record nor a product implementation. See the feature-parity plan for the missing texture, text, graphics, reconciler, scheduler, recovery and product-surface work. The stage plan remains the execution ledger for entry conditions, gates, non-goals and rollback.
Running the gate
npm run typecheck --workspace @agentfiber/render-core # the type-tests are checked here
npm test --workspace @agentfiber/render-core # the suite executes hereBoth halves matter. expectTypeOf is a no-op at runtime, so typecheck
is what verifies the structural claims and test is what proves the file
is reachable at all.
