** This file was created with an agent. **
The compositor is a planned library that sits between the metamodel (state management) and the UI consumers (rendering). Its job is:
fontSize = lerp(keyA.fontSize, keyB.fontSize, t).The data flow:
metamodel (state changes)
→ compositor (inheritance + interpolation)
→ consumers (render glyphs, layout text, draw UI)
Every metamodel state change feeds into the compositor. During animation, the compositor runs every frame.
JS metamodel → JS compositor → JS render
Advantages:
StateComparison feeds directly into the compositor — no
serialization, no schema translation.strict: true, 0 errors, explicit
contracts) provides a strong foundation for building the compositor with the
same discipline.Disadvantages:
requestAnimationFrame
JS metamodel ──[boundary]──→ WASM compositor ──[boundary]──→ JS render
Advantages:
Disadvantages:
WASM metamodel + compositor ──[boundary]──→ JS render
Advantages:
_PotentialWriteProxy system exists because JavaScript has no ownership
semantics. Rust doesn’t need it.Disadvantages:
createClass dynamic class creation doesn’t exist in Rust — becomes
compile-time generics or proc macros.JS metamodel ──[SharedArrayBuffer]──→ WASM compositor ──[results]──→ JS render
Advantages:
Disadvantages:
SharedArrayBuffer.The compositor is not speculative — its core patterns already exist in production, duplicated across two subsystems.
In type-spec-ramp.typeroof.jsx:
SyntheticValue — A computed property: wraps a function + declared
dependency names. call(...resolvedDeps) produces the value.LocalScopeTypeSpecnion — Resolves properties for one level: takes
property generators + a typeSpec model, resolves SyntheticValue
dependencies using the same topologicalSortKahn from the metamodel,
merges with parent properties.HierarchicalScopeTypeSpecnion — Walks the parent chain (like CSS
inheritance), filters inheriting properties via _isInheritingPropertyFn,
merges local + parent scopes.In animation-animanion.mjs:
DependentValue — Same concept as SyntheticValue but for
time-dependent values: wraps a property name + momentT + dependency depth.
This is the precursor of SyntheticValue.LocalScopeAnimanion — Resolves properties at a given momentT by
interpolating between keyMoments. Has its own dependency resolution via
_resolvePropertyDependencies.HierarchicalScopeAnimanion — Walks the parent chain, transforms
globalT through each level’s local t mapping, inherits properties.Both follow exactly the same architecture:
Local scope: raw properties → resolve dependencies → resolved properties
(generators) (topological sort) (Map<name, value>)
Hierarchical: walk parent chain → filter inheriting → merge local + parent
(linked list) (predicate fn) (Map<name, value>)
The difference is the value domain:
In registered-properties-definitions.mjs:
ProcessedPropertiesSystemRecord maps between three worlds:
modelFieldName (e.g., "wght")fullKey (e.g., "axisLocations/wght")registryKey + prefix (for UI components)The code comments acknowledge this is fragile:
“This is mostly to identify the registry BUT double use is to read e.g. complex values from a propertiesMap. There’s a good chance that the double use will collide at some point and has to be refined.”
“Create a ppsRecord on the fly. Hopefully temporary until everything is figured out!!!”
The childrenPropertiesBroomWagonGen flattens nested model structures into
flat prefix + path strings — essentially manually reconstructing what the
metamodel’s getAllPathsAndValues already does.
DependentValue (Animanion) and SyntheticValue (TypeSpecnion) are the same
concept at different levels of maturity: a function with declared
dependencies, resolved via topological sort.
In the compositor, there is just one primitive: a computed property with declared dependencies. Whether those dependencies are:
t…doesn’t matter to the resolution engine. It’s all one dependency graph:
t ──────────────────────┐
keyMoments ─────────────┤
▼
interpolate(fontSize, t, keyMoments) → fontSize: 38.4
│
▼
computeLineHeight(fontSize) → lineHeight: 46.1
│
parentColor ──→ darken(parentColor) → color ▼
computeLeading(fontSize, lineHeight) → leading: 7.7
One graph. One topological sort. One resolution pass. The distinction between “animated property” and “synthetic property” and “inherited-and-transformed property” disappears — they’re all nodes in the dependency graph with different input sources.
An actor on stage has two kinds of properties:
Eigen-properties — fixed, intrinsic to the actor definition. These are what make this actor what it is: the specific font, the specific axis range, the specific text content. These must not inherit from the parent stage. They travel with the actor.
Slots — open channels that accept values from the context: current time
t, position on stage, color theme. These must inherit — the actor needs
them from its environment but doesn’t define them itself.
A parent scope tags values with a purpose. A child scope declares slots that pull from the best-matching purpose via a deterministic chain:
parent scope:
fontSize: 24 → purpose: "body"
fontSize: 18 → purpose: "caption"
fontSize: 36 → purpose: "heading"
child scope (slot "main-text"):
purposeChain: ["body", "default"]
→ binds to purpose "body" → fontSize: 24
Routing is deterministic and data-driven: walk the purposeChain, first match
wins. No scoring functions needed — the metamodel’s struct polymorphism +
SyntheticValue computation already covers flexible cases.
A library actor has internal structure — nested levels that need some slot values routed deeper, possibly transformed:
Stage (provides: time, position, color)
└─ Actor instance
eigen: { font: "Roboto", axisRange: [100, 900] }
slots: { t: ← purpose "playback-time" }
└─ Glyph renderer
slots: { t: ← propagated from parent }
└─ Axis visualizer
slots: { wght: ← transform(t → t * axisRange) }
propagated from parent as purpose "axis-wght"
The same property can change identity as it flows down:
Stage: purpose "playback-time" → t = 0.75
Actor slot: "t" ← "playback-time" → 0.75
transform: (t) => t * axisRange → 675
propagate as purpose "axis-wght"
Glyph slot: "wght" ← "axis-wght" → 675
Beyond the current boolean _isInheritingPropertyFn, each property definition
controls its own inheritance behavior:
interface PropertyDefinition<T = unknown> {
name: string;
kind: "eigen" | "slot";
purposeChain?: string[]; // for slots: what to bind from parent
transform?: SyntheticValue<T>; // computation at inheritance boundary
propagate?: boolean | string; // re-export to children, possibly renamed
defaultValue?: T; // single source of truth for defaults
}
The current Map<string, unknown> provides no type safety. A typo in a
property name silently produces undefined. A wrong-typed value silently
propagates.
The compositor should have typed property definitions:
defineProperty<number>("fontSize", { kind: "slot", purposeChain: ["body-size"] });
defineProperty<ColorValue>("color", { kind: "slot", purposeChain: ["foreground-color"] });
defineProperty<AxisLocationsValue>("axisLocations", { kind: "eigen" });
Properties like axis locations (variable number of axes) and colors (multiple channels + type) should not be flattened into long path-like keys. Instead, they become lightweight typed containers:
scope.get<ColorValue>("color") // → { r: 255, g: 128, b: 0, a: 1.0, type: "rgb" }
scope.get<AxisLocationsValue>("axisLocations") // → { wght: 400, wdth: 100 }
Option C — Lightweight typed containers:
interface SubScope<T> {
readonly value: Readonly<T>;
override(partial: Partial<T>): SubScope<T>; // partial override for inheritance
equals(other: SubScope<T>): boolean; // cheap comparison
}
Sub-scopes are immutable typed wrappers with partial override support (for inheritance) and equality checks (for change detection). They don’t carry the full metamodel machinery — the metamodel manages source data, the compositor manages derived/resolved data.
Sub-scopes inherit as a unit by default. If a child overrides one channel, the intent is usually “I’m defining my own color” — not “I want parent’s green and blue but my own red.” Explicit partial override is opt-in:
parent: color = { r: 255, g: 128, b: 0, a: 1.0, type: "rgb" }
child (no override): color = inherited whole
child (full override): color = { r: 0, g: 0, b: 0, a: 1.0, type: "rgb" }
child (partial override): color = { ...inherited, r: 0 } ← explicit
The flat key model inherits channels independently because it has no concept of grouping. Sub-scopes fix that — the group IS the inheritance unit.
Three layers, tried in order:
PropertyDefinition.defaultValueThis replaces the scattered default sources (_typeSpecDefaultsMap, model
defaultValue, parentPropertyValuesMap fallback) with a single, predictable
resolution order.
Replaces ProcessedPropertiesSystemMap.fromPrefix() and the broom wagon
generators:
const typeSpecMapping = {
"fontSize": { property: "fontSize", type: "number" },
"lineHeight": { property: "lineHeight", type: "number" },
"axisLocations/*": { property: "axisLocations/${key}", type: "number" },
"color": { property: "color", type: ColorValue },
};
Each property has one canonical name and explicit metadata. The triple mapping problem (model key / full key / registry key) is eliminated.
The compositor configuration (property definitions, slot/eigen classification, purpose chains, transforms) is schema — it belongs in the metamodel.
The key insight: CompositorConfig is just another _AbstractStructModel.
The metamodel already has the machinery to define structured, typed, validated,
serializable configuration — why invent a new container?
const CompositorPropertyConfig = _AbstractStructModel.createClass(
"CompositorPropertyConfig", {
kind: EnumModel.createClass("PropertyKind", ["eigen", "slot"]),
purposeChain: _AbstractListModel.createClass("PurposeChain", { Model: StringModel }),
inheritAsUnit: BooleanModel,
propagate: BooleanModel,
}
);
const CompositorConfig = _AbstractStructModel.createClass(
"CompositorConfig", {
fontSize: CompositorPropertyConfig,
color: CompositorPropertyConfig,
axisLocations: CompositorPropertyConfig,
}
);
This follows the same pattern as CoherenceFunction, ForeignKey,
InternalizedDependency, and FallBackValue — all passed as definitions to
createClass and discriminated by instanceof. The compositor config becomes
a frozen static property on the class, like fields and foreignKeys.
Why this is right:
purposeChain entry could be a foreign key to a
purpose registry, enforcing referential integrity.The Animanion’s keyMoment interpolation becomes regular nodes in the dependency
graph. An animated property is a computed property whose dependencies include
t and keyMoment data:
// "fontSize at t" is a computed property
dependencies: ["t", "keyMoments"]
compute: (t, keyMoments) => interpolate(keyMoments, t, "fontSize")
A property that depends on another animated property’s value at the same t
(the DependentValue pattern) is just a dependency edge in the same graph. The
topological sort ensures correct resolution order within a time slice.
This unification means the compositor doesn’t need separate “static resolution” and “animation resolution” stages — one graph, one sort, one pass.
The case for Rust/WASM is strongest when:
If the compositor’s hot loop is “for each of 50 properties, lerp between two floats” — that’s 50 multiplications. JavaScript does that in microseconds. WASM won’t meaningfully improve it.
If the compositor’s hot loop is “for each of 500 glyphs, resolve 20 inherited properties through a 5-level scope chain, interpolate between 3 keyframes with easing curves, and compute bezier path offsets” — now Rust’s performance advantage is real.
Build the compositor in TypeScript first. It integrates naturally with the metamodel, you can iterate on the API quickly, and the typing infrastructure provides safety. Then profile under real workloads.
Web Workers — Move the compositor off the main thread, communicate via
postMessage with transferable objects. This alone may solve frame budget
issues.
Rust/WASM inner loop — If still too slow, port the hot interpolation kernel to Rust/WASM. Not the whole compositor — just the batch computation that runs per frame. The TypeScript compositor becomes a thin orchestrator that delegates number-crunching to WASM.
Full Rust/WASM compositor (Architecture B) — Only if profiling shows the JS→WASM boundary itself is the bottleneck. At this point you have a working TypeScript compositor as a reference implementation, performance data, and a clear understanding of the API surface.
Full WASM stack (Architecture C) — Only if the boundary crossing between metamodel and compositor dominates. This is a major investment and should be justified by measurement, not speculation.
This is the difference between architecture driven by measurement and architecture driven by anticipation.
The generator-based metamorphoseGen protocol (yield ResourceRequirement,
receive resource, continue) translates to Rust in different ways depending on
context:
| Context | Approach | Feasibility |
|---|---|---|
| WASM in browser | JS host drives the generator via imports/exports | ✅ Seamless |
| Rust with all resources in memory | Plain function, no coroutine needed | ✅ Trivial |
| Rust async (disk/network I/O) | async/await — Rust’s async is a state machine (generator under the hood) |
✅ Native |
| Rust embedded in foreign host | Manual state machine or nightly #[coroutine] |
✅ Verbose but works |
Note: runtime schema changes are not a feature that is used or desired —
schemas are always static, defined once at startup. This means Rust’s
compile-time model (generics, proc macros) is a natural fit, not a limitation.
The createClass calls are essentially a build step that happens to run at
runtime because JavaScript doesn’t have a better mechanism.
If both JS and Rust need to understand the same model schemas, an Interface Definition Language eliminates drift:
// metamodel.idl (hypothetical)
struct TypeSpec {
font: String @foreign_key(target: "fonts", constraint: SET_NULL)
size: Number @range(min: 6, max: 144, default: 12)
axes: OrderedMap<String, Number> @ordering(ALPHA)
}
From this, generate:
createClass calls (or metamodel consumes the IDL directly)Standard IDLs (protobuf, JSON Schema) lack metamodel-specific concepts (foreign keys, coherence functions, topological ordering). A custom IDL would be small (~500 lines for the parser) with generators for each target language.
This only matters if Architecture B or C is pursued. For Architecture A (all TypeScript), the existing typed metamodel IS the schema definition.
_PotentialWriteProxy — Rust’s ownership model gives you immutability
for free. The entire 463-line proxy system exists because JavaScript has no
ownership semantics.Object.freeze / Object.defineProperty — Rust’s &T vs &mut T
enforces this at compile time.FreezableMap / FreezableSet — Rust’s im crate provides persistent
data structures, or Arc<HashMap> with clone-on-write.A Rust metamodel would be simpler than the JavaScript version because Rust’s type system handles concerns that JavaScript must enforce at runtime.
createClass metadata into a static formatserialize() / deserializeGen() produceStateComparison, sends to WASM compositorEach stage is independently valuable and informs whether the next stage is justified.
| System | Hierarchical | Per-property control | Routing | Typed | Dependency graph | Interpolation | Sub-scopes | Immutable snapshots |
|---|---|---|---|---|---|---|---|---|
| CSS Cascade | ✅ | ✅ | ❌ | partial | ❌ | ✅ | ❌ | ❌ |
| Design Tokens / Style Dictionary | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| React Context / Vue Provide | ✅ | ✅ | partial | ✅ | ❌ | ❌ | ❌ | ❌ |
Houdini @property |
❌ | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ |
| Game engine property systems | ✅ | partial | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| Compositor (proposed) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
CSS comes closest conceptually — hierarchical inheritance, per-property control
(inherit, initial, unset), typed properties (@property), and
animation interpolation. But CSS lacks dependency-ordered computation
(calc() is expression-level, not graph-level), has no concept of named
routing (purpose → slot), and is locked to the DOM with CSS-specific value
types.
Design token systems (Figma, Style Dictionary) share the “properties cascading through a hierarchy” concept but are much simpler — flat reference substitution, no topological resolution, no animation, no computed properties.
Game engines (Unity DOTS, Unreal) have sophisticated property systems with interpolation and dependency tracking, but they’re entity/component architectures, not scope-based inheritance trees.
Nobody combines all eight capabilities. The compositor is genuinely novel in unifying hierarchical scope inheritance, per-property routing, typed definitions, dependency-resolved computation, and time-based interpolation into a single coherent system.
Typography is inherently parametric and hierarchical:
No existing system was designed for this domain. CSS approximates it but fights against it — CSS inheritance is all-or-nothing per property, has no concept of “this actor carries its own font but inherits time from the stage,” and can’t express “interpolate between these two complete type specifications at t=0.6.”
The compositor is a domain-specific scope engine for parametric typography that happens to be built on general-purpose primitives (dependency graphs, topological sort, immutable state).
What’s genuinely new:
inherit/initial)What’s proven (from the existing codebase):
Risks:
The compositor succeeds when:
The codebase has two systems that view the same concept differently:
type-tools-grid — A table (or cube, or higher-dimensional structure)
of discrete value combinations. E.g., weight [400, 500, 600, 700] × width
[75, 100, 125] = 12 cells, each showing the font at an exact design space
point.t=0.6, you see the interpolated state.These are treated as separate systems. But they’re the same thing at different resolutions:
Discrete: [400]───[500]───[600]───[700]
cell cell cell cell
Continuous: [400]═══════════════════[700]
t=0.0 t=1.0
Hybrid: [400]──▶──[500]──▶──[600]──▶──[700]
cell anim cell anim cell anim cell
A grid axis is a discrete sampling of a continuous range. An animation is a continuous traversal of a value range. Both are parameterized paths — the difference is resolution.
Multi-dimensional grids are already multi-dimensional paths:
wght: [400, 500, 600, 700] ← 4 steps
wdth: [75, 100, 125] ← 3 steps
opsz: [12, 24, 48] ← 3 steps
= 36 cells in a 3D grid (cube)
As a continuous path, this becomes a trajectory through 3D space — a diagonal, a spiral, an L-shape, or any arbitrary curve.
interface Dimension {
name: string; // "wght"
range: [number, number]; // [400, 700]
samples?: number[] | SamplingStrategy; // discrete points or auto
easing?: EasingFunction; // interpolation between samples
}
interface ValueSpace {
dimensions: Dimension[];
// Discrete: enumerate all sample combinations
cells(): Iterator<Record<string, number>>;
// Continuous: resolve at a point in normalized space
at(coordinates: Record<string, number>): Record<string, number>;
// Hybrid: resolve at t along a path through the space
atPath(path: SpacePath, t: number): Record<string, number>;
}
A grid is cells(). An animation is atPath(path, t). A cell-to-cell
animation is atPath(cellSequencePath, t) where the path visits each cell.
Discrete → Continuous: The mouse position within the grid becomes a continuous coordinate in the value space. The grid cells are discrete landmarks, but the mouse reveals everything in between:
Grid cell [wght=500, wdth=100] at pixel (200, 150)
Mouse hovers at (230, 160)
→ 60% toward [wght=600] and 20% toward [wdth=125]
→ Interpolated: wght=560, wdth=105
The cell under the cursor shows the interpolated state at the exact mouse position. The grid becomes a live design space explorer — not a table of static snapshots, but a window into a continuous space where discrete cells are reference points.
Continuous → Discrete: An animation is sampled at specific points:
ValueSpace produces frame-accurate snapshots.The ValueSpace feeds into the compositor as a property source:
cells()t-resolved values from atPath() become scope propertiesThe compositor doesn’t need to know whether a value came from a discrete cell
or continuous interpolation — it’s just a value in the scope. ValueSpace
handles the distinction upstream.
A complex animation may include actors with fixed frame rates — most notably
video. The typography animation interpolates continuously, but the video must
snap to discrete frame boundaries. The ValueSpace handles this as a
synchronization problem:
Video actor:
dimension: "frame"
range: [0, 300] ← 300 frames (10s at 30fps)
samples: fixed(30) ← one sample per frame
Typography animation:
dimension: "t"
range: [0.0, 1.0]
samples: continuous
Synchronization:
keyMoment at t=0.0 ←→ frame 0
keyMoment at t=0.5 ←→ frame 150
keyMoment at t=1.0 ←→ frame 300
KeyMoments bind to keyFrame positions, creating a synchronization contract
between the continuous typography world and the frame-based video world. The
compositor resolves the video actor’s t to the nearest frame boundary —
snapping to discrete frames — while typographic properties interpolate
continuously around it.
This works in both directions:
t, the video actor snaps to the
corresponding frame, typography interpolates smoothly.The binding between keyMoments and keyFrames is itself data — expressible as a
CompositorConfig property on the video actor, defining the frame rate and the
synchronization points. The metamodel manages it; the compositor resolves it.
This is post-compositor work — the compositor’s scope system must exist first,
then ValueSpace layers on top as a property source that unifies discrete and
continuous value resolution.
The metamodel’s job is to be correct. The compositor’s job is to be fast.
TypeScript excels at correct — the typing pass we just completed proves this. Rust excels at fast — when there’s measurable computation to optimize.
Build for correctness first. Optimize for speed when measurement demands it.
The compositor’s job is also to be simple — to make the complex reality of parametric typographic scope resolution feel natural and inevitable to the developer using it. The eight capabilities should feel like one coherent idea, not eight bolted-together features.