stabilize architecture docs: address review findings and advance to stable

Critical fixes:
- Restructure pointers.md: move setNode prop-key writes section under
  its own heading (was incorrectly nested under selectNode)
- Add Context/Density/Direction/RenderContext documentation section
  to host-config.md (was only a brief constraint bullet)
- Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter
  from status: draft → status: stable (decisions are driving implementation)
- Add error handling philosophy section to README

Warning/suggestion fixes:
- Add isUElement null check (node !== null) to schema.md discriminator table
- Add UjsxEnvelope convenience type documentation to events.md
- Add Direction Unicode arrow naming note to transforms.md
- Standardize all cross-references from absolute docs/research/ paths
  to relative ../research/ paths across all architecture docs
- Fix schema.md ADR references to use relative paths
- Reduce redundancy between transforms.md and host-config.md Direction notes
- Update all architecture doc frontmatter from draft → stable

Deferred:
- Performance model section (reconciler not yet built)
- Concepts/glossary document (low ROI at current scale)
- Line counts in source references (would date quickly)
This commit is contained in:
2026-05-18 16:10:24 +00:00
parent 23659233ca
commit 0d5b9d5ea8
16 changed files with 167 additions and 61 deletions

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -76,7 +76,7 @@ Uses UJSX as a direct dependency. Workflow templates are `UNode` trees. Renders
- **graphology DAG** — structural analysis, cycle detection, topological sort via a `HostConfig`
- **Reactive execution engine** — runtime workflow execution with signal-based status propagation
See `docs/research/reconciler/05-flowgraph-host-configs.md` for the planned integration.
See `../research/reconciler/05-flowgraph-host-configs.md` for the planned integration.
### Desktop UI (Spoke HUD)
@@ -118,11 +118,23 @@ last_updated: YYYY-MM-DD
ADR documents use a separate `Status` field in their body: `Proposed`, `Accepted`, `Deprecated`, or `Superseded`. ADRs never revert from `Accepted`. Note that ADR frontmatter (`status: draft`) refers to the **document's editorial status** (is the writing complete?), while the body `Status` refers to the **decision's status** (is this decision finalized?). A `Proposed` decision can have a `draft` document; an `Accepted` decision has a `stable` document.
## Error Handling Philosophy
UJSX follows a tiered approach to errors:
- **Programmer errors throw**. Invalid arguments (`h()` called without a type), missing required parameters, or `transform()` failing to find a matching rule — these are bugs that should surface immediately with a stack trace. Failing silently makes debugging harder.
- **Operational conditions are no-ops with defined behavior**. `selectNode` returning `undefined` for invalid paths, `setNode` returning the node unchanged for unresolvable paths, `Fragment` with no children producing an empty array — these are not errors, they are well-defined boundary conditions. The caller is responsible for checking the result.
- **Host errors propagate**. If `HostConfig.createInstance` throws, the error bubbles up through `mountNode` with no host involvement. UJSX does not catch or suppress host errors. The current implementation has no `handleError` hook on `HostConfig` — whether to add one is an open question (see [host-config.md](host-config.md) Open Question 4).
- **Recovery is the caller's responsibility**. `ReactiveRoot.subscribe()` returns a dispose function; if the caller discards it, the effect leaks. `unmount()` is a stub. There is no automatic retry, circuit breaking, or error event emission for operational failures. These concerns belong to the host or consumer, not to the core library.
## References
- UJSX research index: `docs/research/README.md`
- Reconciler research: `docs/research/reconciler/`
- SDD process: `docs/sdd_process.md`
- UJSX research index: `../research/README.md`
- Reconciler research: `../research/reconciler/`
- SDD process: `../sdd_process.md`
- Preact signals-core: `@preact/signals-core`
- TypeBox: `@alkdev/typebox`
- Taskgraph_ts architecture pattern: `/workspace/@alkdev/taskgraph_ts/docs/architecture/`

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---

View File

@@ -1,11 +1,11 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
# ADR-001: HTML-agnostic core
**Status**: Proposed
**Status**: Accepted
## Context

View File

@@ -1,11 +1,11 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
# ADR-002: TypeBox Module as type registry
**Status**: Proposed
**Status**: Accepted
## Context

View File

@@ -1,11 +1,11 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
# ADR-003: Preact signals-core for reactivity
**Status**: Proposed
**Status**: Accepted
## Context

View File

@@ -1,11 +1,11 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
# ADR-004: `key` as a First-Class Field on `UElement`
**Status**: Proposed
**Status**: Accepted
## Context
@@ -70,7 +70,7 @@ The TypeBox schema uses `Type.Optional(Type.String())` for `key`, so validation
## References
- Key field design research: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- Key field design research: `../../research/reconciler/00-KEY-FIELD-DESIGN.md`
- Schema architecture: [schema.md](../schema.md)
- Element factory: [element-factory.md](../element-factory.md)
- Reconciler algorithm: [reconciler.md](../reconciler.md)

View File

@@ -1,11 +1,11 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
# ADR-005: Signal-Driven Updates Over Tree Diffing
**Status**: Proposed
**Status**: Accepted
## Context
@@ -58,8 +58,8 @@ This means 90% of updates (property changes) bypass tree diffing entirely. The r
## References
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
- Key-based children reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- Reactive → Host bridge research: `../../research/reconciler/01-reactive-host-bridge.md`
- Key-based children reconciliation: `../../research/reconciler/02-key-based-children-reconciliation.md`
- Reconciler architecture: [reconciler.md](../reconciler.md)
- Reactive layer: [reactive-layer.md](../reactive-layer.md)
- HostConfig interface: [host-config.md](../host-config.md)

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -155,4 +155,4 @@ This is documented in the schema architecture ([schema.md](schema.md) — Known
- Schema architecture: `docs/architecture/schema.md`
- Key field ADR: [decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md)
- Reconciler architecture: [reconciler.md](reconciler.md)
- Key-based reconciliation research: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- Key-based reconciliation research: `../research/reconciler/02-key-based-children-reconciliation.md`

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -73,6 +73,19 @@ type UjsxEventMap = {
The event map defines the known event types and their payload shapes. Each key is an event type string; each value is the expected payload structure.
## UjsxEnvelope
```typescript
type UjsxEnvelope<TType extends keyof UjsxEventMap = keyof UjsxEventMap> = EventEnvelope<
TType,
UjsxEventMap[TType]
>;
```
A convenience type alias that parameterizes `EventEnvelope` with `UjsxEventMap`. Instead of writing `EventEnvelope<"root.render", { childCount: number }>`, consumers write `UjsxEnvelope<"root.render">`. The type parameter defaults to the full key union, so `UjsxEnvelope` alone means "an envelope for any UJSX event."
This type is exported from `src/core/events.ts` and re-exported from the barrel.
| Event | When emitted | Payload |
|-------|-------------|---------|
| `root.render` | HostConfig calls `render()` on a root | `childCount` of the rendered tree |
@@ -136,7 +149,7 @@ The event map does not include error events (e.g., `instance.error`, `render.err
### No cleanup on unmount
The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [lifecycle.md](lifecycle.md) and the unmount & dispose research (`docs/research/reconciler/03-unmount-dispose-support.md`).
The current `unmount()` implementation in `HostConfig` does not tear down event subscriptions. If a pubsub emitter was wired to the host's `emit()` during render, unmount does not unsubscribe. This gap is shared with the broader reconciler disposal gap — see [lifecycle.md](lifecycle.md) and the unmount & dispose research (`../research/reconciler/03-unmount-dispose-support.md`).
## Constraints

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -82,6 +82,85 @@ interface Root<TTag extends string, Instance, RootCtx> {
The `Context` connection is intentional: UJSX wants hosts to make density-aware and target-aware decisions without the host needing to know about signal internals. `Context` provides a reactive `ContextValue` that hosts can read during `createInstance` or `commitUpdate`.
## Context, Density, Direction & RenderContext
The `context.ts` module exports a set of related types and a class that support adaptive rendering and directional transforms:
### Context (class)
```typescript
class Context {
constructor(initial?: Partial<ContextValue>)
get(): ContextValue
get signal(): ReadonlySignal<ContextValue>
set(partial: Partial<ContextValue>): void
subscribe(fn: (value: ContextValue) => void): () => void
fork(overrides: Partial<ContextValue>): Context
}
```
`Context` wraps a Preact `signal<ContextValue>` and provides reactive access to context data. Hosts read context during `createInstance` or `commitUpdate` to adapt rendering (e.g., switching layouts based on `density`). The `signal` getter exposes the underlying `ReadonlySignal` for composition with other reactive primitives.
- **`get()`** — returns the current `ContextValue` (non-reactive read).
- **`set(partial)`** — shallow-merges `partial` into the current value inside a `batch()`, triggering any subscriptions.
- **`subscribe(fn)`** — calls `fn` on every change via `effect()`. Returns a dispose function.
- **`fork(overrides)`** — creates a new `Context` with the current values shallow-merged with `overrides`. Forked contexts are independent — changes to the fork do not propagate to the parent.
### ContextValue
```typescript
interface ContextValue {
density: Density;
target: string;
metadata: Record<string, unknown>;
}
```
The shape of a context's value. Defaults:
- `density`: `"full"`
- `target`: `"markdown"`
- `metadata`: `{}`
### Density
```typescript
type Density = "full" | "compact" | "minimal"
```
Controls rendering granularity for hosts that support adaptive output. `full` means render everything; `compact` and `minimal` are host-defined — UJSX passes the value through, it does not interpret it. A desktop UI host might use `compact` to hide labels and `minimal` to render only essential controls.
### Direction
```typescript
type Direction = "ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx"
```
Six directional strings pairing into three bi-directional channels (markdown, JSON path, HTML). `Direction` is defined in `context.ts` because it governs both transform rules and render context — it's not transform-specific.
The `→` character in direction strings is a Unicode right arrow (U+2192). This was chosen for readability over alternatives like `ujsx-to-mdast` or `ujsx2mdast`. Consumers should be aware of the non-ASCII characters in IDE autocompletion and linting contexts.
### RenderContext
```typescript
interface RenderContext extends ContextValue {
direction: Direction;
}
```
A convenience type that adds `direction` to `ContextValue`. Used primarily by the transform system to carry conversion direction alongside context data. The transform `ctx` factory function accepts a `Direction` and returns a `TransformContext` (which includes `direction`), not a `RenderContext``RenderContext` exists for consumers that want to type-narrow the full context shape.
### Exports
All context types are available from the barrel export (`@alkdev/ujsx`) and the `context` sub-path:
```typescript
import { Context } from "@alkdev/ujsx/context"; // class
import type { Density, Direction, RenderContext } from "@alkdev/ujsx/context"; // types
```
`Context` is a runtime export; `Density`, `Direction`, and `RenderContext` are type-only exports.
## createRoot()
```typescript
@@ -170,7 +249,7 @@ unmount() {
This means calling `unmount()` followed by creating a new root on the same container will likely result in leaked instances and stale signal effects.
The reconciler architecture ([reconciler.md](reconciler.md)) and lifecycle management ([lifecycle.md](lifecycle.md)) address both gaps. The research documents (`docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`) provide the detailed implementation plans.
The reconciler architecture ([reconciler.md](reconciler.md)) and lifecycle management ([lifecycle.md](lifecycle.md)) address both gaps. The research documents (`../research/reconciler/01-reactive-host-bridge.md` and `../research/reconciler/03-unmount-dispose-support.md`) provide the detailed implementation plans.
### Event IDs Use `Date.now()`
@@ -193,7 +272,7 @@ The reconciler solves this by maintaining a fiber tree alongside the instance tr
- **`render()` accepts any `UNode`** — if the node is a `URoot`, its children are mounted directly. If the node is any other `UNode` (element or primitive), it is wrapped in an array and mounted as a single top-level element without a root container.
- **Function components are synchronous and transparent** — they receive props and children, return a `UNode`, and produce no host instance. The reconciler research discusses how to handle components that return different tree shapes across renders.
- **`Context` is always present** — `createRoot()` guarantees a `Context` exists on the `Root`. If none is provided, a default is created with `density: "full"`, `target: "markdown"`, and empty `metadata`.
- **`RenderContext` extends `ContextValue`** — the `Direction` type (`"ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx"`) and `RenderContext` interface (which adds `direction` to `ContextValue`) are exported from the `context` sub-path. `RenderContext` is primarily used by the transform system to specify conversion direction. `Density` (`"full" | "compact" | "minimal"`) controls rendering granularity for hosts that support adaptive output.
- **`RenderContext` extends `ContextValue`** — adds `direction` to the context value. See [Context, Density, Direction & RenderContext](#context-density-direction--rendercontext) for full documentation of these types.
- **`container` is opaque** — UJSX passes it to `createRootContext` and stores it on `Root`, but never inspects it. The host defines what it means.
- **Mount is depth-first, post-order** — children are fully constructed before being appended to their parent. Hosts can rely on this ordering invariant.
@@ -216,4 +295,4 @@ The reconciler solves this by maintaining a fiber tree alongside the instance tr
- Context: `src/core/context.ts``Context` class with signal-based values
- Reconciler architecture: [reconciler.md](reconciler.md)
- Lifecycle management: [lifecycle.md](lifecycle.md)
- Reconciler research: `docs/research/reconciler/01-reactive-host-bridge.md` and `03-unmount-dispose-support.md`
- Reconciler research: `../research/reconciler/01-reactive-host-bridge.md` and `../research/reconciler/03-unmount-dispose-support.md`

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -176,7 +176,7 @@ All disposal operations must be idempotent. Calling `dispose()` twice must not e
## References
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
- Unmount & dispose research: `../research/reconciler/03-unmount-dispose-support.md`
- Reconciler architecture: [reconciler.md](reconciler.md)
- HostConfig interface: [host-config.md](host-config.md)
- Reactive layer: [reactive-layer.md](reactive-layer.md)

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -78,19 +78,6 @@ If the current node is not a `UElement` (i.e., it's a `UPrimitive` — a string,
When a string segment resolves to a prop value that is a primitive (string, number, boolean, null), `selectNode` returns `undefined`. Only non-null object prop values — including arrays, since `typeof [] === "object"` — can be navigation targets. This means `props.items` where `items` is an array can be navigated into, but `props.title` where `title` is a string cannot.
`setNode` mirrors this behavior for writes: a non-numeric string segment sets `props[segment] = value`, performing a shallow merge of that key into the element's props. This allows targeted prop updates via path-based navigation.
### setNode prop-key writes
Non-numeric path segments in `setNode` set values into the `props` object:
```typescript
setNode(root, ["title"], someNode)
// Produces: { ...rootEl, props: { ...rootEl.props, title: someNode } }
```
This shallow-merges a key into `props`. The `value` must be a valid `PropValue` (or `UNode`) for the result to remain type-safe.
## setNode
```typescript
@@ -123,6 +110,19 @@ This structural sharing means `setNode` is O(depth) in allocations, not O(size o
When the head segment is a valid non-negative integer, `setNode` shallow-copies the `children` array and replaces the element at that index. Out-of-range indices are ignored — the copy is made but no element is replaced. This is a defensive choice: silent no-op is preferable to throwing or growing the array.
### Prop-key path writes
Non-numeric path segments set values into the `props` object:
```typescript
setNode(root, ["title"], someValue)
// Produces: { ...rootEl, props: { ...rootEl.props, title: someValue } }
```
This shallow-merges a key into `props`. The `value` argument must be a valid `PropValue` (or `UNode`) for the result to remain type-safe. Unlike numeric segments (which navigate into `children`), non-numeric segments write to `props` at the current level without deeper navigation — the remaining path tail determines what happens next.
This mirrors `selectNode`'s resolution: where `selectNode` reads `props[segment]` and navigates into non-null object values, `setNode` writes `props[segment] = value` as a shallow merge.
## Relationship to the Reconciler
`ValuePointer`, `selectNode`, and `setNode` are **not** the reconciler's update mechanism. They are lower-level utilities that the reconciler can use internally. The reconciler's fiber tree manages structural changes (diffing, adding, removing children). Pointers handle targeted reads and writes at known positions — a finer granularity than the reconciler's subtree updates.
@@ -159,4 +159,4 @@ Paths are exact sequences of segments. There is no support for `*` (any child),
- Source: `src/core/pointer.ts`
- UNode schema: `src/core/schema.ts`
- Preact signals: `@preact/signals-core`
- Reconciler research: `docs/research/reconciler/` (see 01-reactive-host-bridge.md and 02-key-based-children-reconciliation.md for how selectors and path-based targeting integrate with the fiber tree)
- Reconciler research: `../research/reconciler/` (see 01-reactive-host-bridge.md and 02-key-based-children-reconciliation.md for how selectors and path-based targeting integrate with the fiber tree)

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -152,6 +152,6 @@ Calling `render()` a second time replaces `this.renderDisposer` without disposin
- ADR-003: `docs/architecture/decisions/003-preact-signals-for-reactivity.md`
- Reconciler architecture: [reconciler.md](reconciler.md)
- Lifecycle management: [lifecycle.md](lifecycle.md)
- Reactive → Host bridge research: `docs/research/reconciler/01-reactive-host-bridge.md`
- Unmount & dispose research: `docs/research/reconciler/03-unmount-dispose-support.md`
- Reactive → Host bridge research: `../research/reconciler/01-reactive-host-bridge.md`
- Unmount & dispose research: `../research/reconciler/03-unmount-dispose-support.md`
- Preact signals-core: `@preact/signals-core`

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -209,7 +209,7 @@ Optimizations are applied in order:
4. **Add `Value.Mutate`** (if function values are handled correctly) — preserve reference identity
5. **Add `Value.Diff` for prop payloads** (optional, catch errors) — granular diff payloads for hosts that want them
> See the research in `docs/research/reconciler/04-typebox-optimization-layer.md` for detailed analysis.
> See the research in `../research/reconciler/04-typebox-optimization-layer.md` for detailed analysis.
## File Structure
@@ -256,11 +256,11 @@ Host implementations that currently only support mount-only rendering will start
## References
- Reconciler research: `docs/research/reconciler/README.md`
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- Reactive → Host bridge: `docs/research/reconciler/01-reactive-host-bridge.md`
- Children reconciliation: `docs/research/reconciler/02-key-based-children-reconciliation.md`
- TypeBox optimization layer: `docs/research/reconciler/04-typebox-optimization-layer.md`
- Reconciler research: `../research/reconciler/README.md`
- Key field design: `../research/reconciler/00-KEY-FIELD-DESIGN.md`
- Reactive → Host bridge: `../research/reconciler/01-reactive-host-bridge.md`
- Children reconciliation: `../research/reconciler/02-key-based-children-reconciliation.md`
- TypeBox optimization layer: `../research/reconciler/04-typebox-optimization-layer.md`
- Lifecycle management: [lifecycle.md](lifecycle.md)
- HostConfig interface: [host-config.md](host-config.md)
- Reactive layer: [reactive-layer.md](reactive-layer.md)

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -124,7 +124,7 @@ function isUPrimitive(node: UNode): node is UPrimitive
| Guard | Logic | Discriminator |
|-------|-------|---------------|
| `isUElement` | `typeof === "object" && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys AND `type` is not `"root"` |
| `isUElement` | `typeof node === "object" && node !== null && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys, is not null, and `type` is not `"root"` |
| `isURoot` | `typeof === "object" && "type" in node && node.type === "root"` | `type === "root"` |
| `isUPrimitive` | `typeof === "string" \|\| typeof === "number" \|\| typeof === "boolean" \|\| node === null` | Not an object |
@@ -151,7 +151,7 @@ The reconciler architecture (see [reconciler.md](reconciler.md) and [ADR-004](de
## Open Questions
1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [ADR-004](decisions/004-key-as-first-class-field.md) and research: [00-KEY-FIELD-DESIGN.md](../../research/reconciler/00-KEY-FIELD-DESIGN.md).
1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [ADR-004](decisions/004-key-as-first-class-field.md) and research: [00-KEY-FIELD-DESIGN.md](../research/reconciler/00-KEY-FIELD-DESIGN.md).
2. **Should `UPrimitive` include `undefined`?** Currently `null` represents an explicitly empty value. `undefined` means "absent" and should not appear as a tree node. This is consistent with how JSX treats `undefined` children (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this.
3. **Should `UniversalProps` constrain value types per-host?** The open schema allows any `PropValue` for any key. A host that wants stricter prop contracts (e.g., `onClick` must be a function, `className` must be a string) must validate at its own boundary. A future host-typed props system could be layered on top without changing the base schema.
@@ -160,6 +160,6 @@ The reconciler architecture (see [reconciler.md](reconciler.md) and [ADR-004](de
- Source: `src/core/schema.ts`
- Key field ADR: [decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md)
- Reconciler architecture: [reconciler.md](reconciler.md)
- Key field design research: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- TypeBox Module as type registry: `docs/architecture/decisions/002-typebox-module-as-registry.md`
- HTML-agnostic core: `docs/architecture/decisions/001-html-agnostic-core.md`
- Key field design research: `../research/reconciler/00-KEY-FIELD-DESIGN.md`
- TypeBox Module as type registry: [decisions/002-typebox-module-as-registry.md](decisions/002-typebox-module-as-registry.md)
- HTML-agnostic core: [decisions/001-html-agnostic-core.md](decisions/001-html-agnostic-core.md)

View File

@@ -1,5 +1,5 @@
---
status: draft
status: stable
last_updated: 2026-05-18
---
@@ -117,6 +117,8 @@ The six directions pair into three bi-directional channels:
These are the directions currently defined. Additional directions (e.g., `ujsx→dom`, `dom→ujsx`) can be added by extending the `Direction` union in `context.ts`. The registry itself is generic and does not enumerate directions.
The `→` character in direction strings is Unicode U+2192 (RIGHTWARDS ARROW), chosen for readability over alternatives like `ujsx-to-mdast`. See [host-config.md](host-config.md) for full documentation of `Direction`, `Density`, and `RenderContext`.
## Known Gaps
### No transform composition beyond next