skip to the page

@jam-overture/loom

The tree, the delta, the ids, the builders, the Gate and the pipeline. Start here.

Everything below is exported from that import. The names, the signatures and the sentences are read from the package itself rather than written here, so this page says what the copy of Loom in your node_modules says — and it changes in the same pull request the code does.

552 exports, in 81 modules. Generated from ./dist/index.d.ts, which is the declaration file this package publishes for @jam-overture/loom.

Nothing to install first. Everything this import loads arrives with @jam-overture/loom itself.

No import here has everything behind it — not even this one. @jam-overture/loom publishes 552 of the 1,298 names this package publishes — more than any other import, and still less than half. The other 746 are behind one of the 16 other imports, and 14 of those 16 publish nothing this one does. The imports do not nest: a name that is not on this page is not a name that does not exist, and the search at the top of the page says which import it comes from.

Some of these names are published elsewhere too: 5 by @jam-overture/loom/react and 8 by @jam-overture/loom/sdk — the same declarations reached through two doors, so either import gives you the same thing.

The catalogue a model is shown

catalogue

What a deployment can build with, as data.

CataloguedProptype

type CataloguedProp = {
    readonly name: string;
    readonly required: boolean;
};

CataloguedPrimitivetype

type CataloguedPrimitive = {
    readonly type: PrimitiveType;
    /** One line, written by the primitive's author, about what it is for. */
    readonly description: string;
    /**
     * Declared props, name-sorted. `undefined` when the declared schema is not an
     * object schema and its keys therefore cannot be enumerated — unknown, which
     * is not the same claim as "none".
     */
    readonly props: readonly CataloguedProp[] | undefined;
    readonly slots: readonly SlotName[];
    /**
     * The binding names this primitive reads an answer under, in declaration
     * order. `undefined` when its author has not said — which, like `props`, is
     * not the same claim as "none" and is not rounded to one.
     *
     * An entry is a name, or the prop that gives one and the name it falls back
     * to. The second form is carried out rather than flattened to its
     * default, because the two say different things to a model: one name it must
     * write, against one name it may replace by setting a prop.
     *
     * It is the one field here that is about what the primitive *asks the
     * deployment for* rather than about what a tree may write on it, and it is
     * here because the name in a binding belongs to the primitive. A model is
     * told which sources exist by the data catalogue; without this it was never
     * told which names those answers may be filed under, so the only name it
     * could write was one it had seen on the page already.
     */
    readonly reads: readonly BindingDeclaration<BindingName>[] | undefined;
};

PrimitiveCataloguetype

type PrimitiveCatalogue = readonly CataloguedPrimitive[];

catalogueFieldsfunction

const catalogueFields: (schema: ZodTypeAny) => readonly CataloguedProp[] | undefined

ClosedChoicetype

A prop whose accepted values can be listed rather than invented.

type ClosedChoice = {
    readonly name: string;
    readonly options: readonly (string | boolean)[];
};

closedChoicesfunction

const closedChoices: (schema: ZodTypeAny) => readonly ClosedChoice[]

Adapter

data/adapter

The host's half of the data seam: what answers a binding.

SourceRequesttype

type SourceRequest<TParams> = {
    readonly params: TParams;
    /** The render request's opaque host context — audience, locale, tenant. */
    readonly context: JsonObject | undefined;
    /**
     * Aborted once the render has stopped waiting for this binding, which it will
     *. An adapter that ignores it still cannot delay the page; what it holds
     * on to is a connection whose answer nothing will read. Pass it to whatever
     * does the IO — `fetch`, a driver's query options — and the pool gets it back.
     */
    readonly signal: AbortSignal | undefined;
};

SourceFailuretype

What an adapter says when it cannot answer.

type SourceFailure = {
    /**
     * `unavailable` is "ask again later" — a timeout, a dead connection.
     * `refused` is "not for you" — an integration nobody connected, a record this
     * audience may not see. They are separate because a primitive shows different
     * things for them, and because only one of the two is worth retrying.
     */
    readonly code: "unavailable" | "refused";
    readonly detail: string;
};

DataAdapterinterface

interface DataAdapter<TParams = JsonObject, TAnswer extends JsonValue = JsonValue> {
    readonly fetch: (request: SourceRequest<TParams>) => Promise<Result<TAnswer, SourceFailure>>;
}

DataUnavailabletype

Why a binding has no value. Every one of these is something a primitive may be told about and a diagnostic says out loud; none of them is an exception anyone catches.

type DataUnavailable = {
    readonly reason: "no-such-source" | "not-resolved" | "invalid-params" | "invalid-answer" | "adapter-threw" | SourceFailure["code"];
    readonly detail: string;
};

describeDataUnavailablefunction

const describeDataUnavailable: (unavailable: DataUnavailable) => string

SourceDefinitiontype

type SourceDefinition<TParams, TAnswer extends JsonValue> = {
    readonly id: string;
    /** One line, for the catalogue. A model choosing a source has this to go on. */
    readonly description: string;
    /**
     * What the tree may ask with. Required, with no "anything" option: params are
     * AI-authored, and a source that declines to say what it accepts is asking the
     * deployment to trust a model's guess about its own query.
     */
    readonly params: ZodType<TParams, ZodTypeDef, unknown>;
    /**
     * What it promises to answer with. Checked at the seam even though the adapter
     * is typed by it, because the adapter's type is a claim about a database and
     * the schema is the only thing that makes it true on the day the column
     * changed.
     */
    readonly answers: ZodType<TAnswer, ZodTypeDef, unknown>;
    readonly adapter: DataAdapter<TParams, TAnswer>;
};

SourceEntrytype

A definition with its types erased, which is what a heterogeneous registry can hold. defineSource is the only way to build one, so the erasure happens once, where both schemas and the adapter are still in hand and can be proved to agree.

type SourceEntry = {
    readonly id: string;
    readonly description: string;
    readonly declaredParams: readonly CataloguedProp[] | undefined;
    /** Total: validates, calls, catches, validates again. Never rejects. */
    readonly answer: (params: JsonObject, context: JsonObject | undefined, signal?: AbortSignal) => Promise<Result<JsonValue, DataUnavailable>>;
};

defineSourcefunction

Declares a source. The generic parameters are inferred from the schemas, so the adapter is checked against what its own declaration produces at the point of declaration rather than at the point of registration.

Shown in use on Where the content comes from — Your half: registering a source, When nothing comes back — The half that is not about the answer and What a form posts to — Your half: registering an endpoint

const defineSource: <TParams, TAnswer extends JsonValue>(definition: SourceDefinition<TParams, TAnswer>) => SourceEntry

RegisteredSourcetype

type RegisteredSource = SourceEntry & {
    readonly id: SourceId;
};

DataRegistryErrortype

type DataRegistryError = {
    readonly code: "invalid-source-id";
    readonly id: string;
} | {
    readonly code: "duplicate-source-id";
    readonly id: string;
};

describeDataRegistryErrorfunction

Shown in use on Where the content comes from — Your half: registering a source

const describeDataRegistryError: (error: DataRegistryError) => string

DataRegistryinterface

interface DataRegistry {
    readonly source: (id: SourceId) => RegisteredSource | undefined;
    /** In registration order, so a catalogue reads predictably. */
    readonly sources: readonly RegisteredSource[];
}

createDataRegistryfunction

Building the registry is pure and calls no adapter, so registering cannot run someone's query as a side effect of an import.

Shown in use on Where the content comes from — Your half: registering a source

const createDataRegistry: (entries: readonly SourceEntry[]) => Result<DataRegistry, DataRegistryError>

Binding

data/binding

What a node asks the host to answer, as it appears in the tree.

Bindingtype

type Binding = {
    readonly source: SourceId;
    readonly params: JsonObject;
};

NodeBindingstype

Bindings on one node, by the name the primitive reads them under.

type NodeBindings = ReadonlyMap<BindingName, Binding>;

BindingErrortype

type BindingError = {
    /** The path of the offending entry, for a diagnostic a person can act on. */
    readonly path: string;
    readonly message: string;
};

describeBindingErrorfunction

const describeBindingError: (error: BindingError) => string

parseBindingsfunction

Parses the value of loom:data. Total, like every other parse of something that came out of storage: a malformed binding map is reported and the node renders without data, rather than throwing on a page nobody can then see.

const parseBindings: (declared: unknown) => Result<NodeBindings, BindingError>

Catalogue

data/catalogue

What a deployment can ask about, as data.

CataloguedSourcetype

type CataloguedSource = {
    readonly id: SourceId;
    /** One line, written by the source's author, about what it answers. */
    readonly description: string;
    /**
     * Declared params, name-sorted. `undefined` when the declared schema is not an
     * object schema and its keys therefore cannot be enumerated — unknown, which
     * is not the same claim as "none".
     */
    readonly params: readonly CataloguedProp[] | undefined;
};

DataCataloguetype

type DataCatalogue = readonly CataloguedSource[];

dataCataloguefunction

const dataCatalogue: (registry: DataRegistry) => DataCatalogue

Planning the reads

data/plan

What a tree wants to know, worked out before anything is asked.

RequestKeytype

Identifies one question. Two bindings sharing it share an answer.

type RequestKey = string;

PlannedRequesttype

type PlannedRequest = {
    readonly key: RequestKey;
    readonly source: SourceId;
    readonly params: JsonObject;
};

PlannedBindingtype

type PlannedBinding = {
    readonly nodeId: NodeId;
    readonly name: BindingName;
    readonly key: RequestKey;
};

BindingProblemtype

A node whose loom:data could not be read at all.

type BindingProblem = {
    readonly nodeId: NodeId;
    readonly error: BindingError;
};

DataPlantype

type DataPlan = {
    /** Deduplicated, in first-encountered order. */
    readonly requests: readonly PlannedRequest[];
    readonly bindings: readonly PlannedBinding[];
    readonly problems: readonly BindingProblem[];
};

EMPTY_DATA_PLANvalue

const EMPTY_DATA_PLAN: DataPlan

planIsEmptyfunction

const planIsEmpty: (plan: DataPlan) => boolean

planTreeDatafunction

const planTreeData: (tree: LoomTree) => DataPlan

planDataInfunction

The same plan, for a root that is not a stored tree.

const planDataIn: (root: LoomNode) => DataPlan

What a resolved binding says

data/resolution

The answers, indexed the way a render walk needs them.

DataOutcometype

A binding either has an answer or has a named reason it does not. There is no third state, and in particular there is no way to express "empty" as a failure — a source that legitimately answers with nothing answers ready with an empty list. Collapsing the two is the mistake the portal spent a run undoing: a reader who takes "we could not reach your services" for "you have no services" concludes their data is gone.

type DataOutcome = {
    readonly status: "ready";
    readonly value: JsonValue;
} | {
    readonly status: "unavailable";
    readonly unavailable: DataUnavailable;
};

NodeDatatype

One node's answers, by binding name. Null-prototype for the reason staticPrimitiveResolver gives: binding names are identifiers and constructor and valueOf are valid ones, so a plain object would answer loom.data.constructor with a function off Object.prototype.

type NodeData = Readonly<Record<string, DataOutcome>>;

NO_DATAvalue

const NO_DATA: NodeData

nodeDataOffunction

One node's answers, built from a plain record.

const nodeDataOf: (answers: Readonly<Record<string, DataOutcome>>) => NodeData

NodeDataProblemtype

What a node's data could not do, in the seam's own vocabulary.

type NodeDataProblem = {
    readonly kind: "misdeclared";
    readonly error: BindingError;
} | {
    readonly kind: "unavailable";
    readonly name: BindingName;
    readonly source: SourceId;
    readonly unavailable: DataUnavailable;
};

DataResolutioninterface

interface DataResolution {
    /**
     * Always answers. `NO_DATA` means *this node asked for nothing* — it is not a
     * way to say "I have no answers for you", and an implementation that uses it as
     * one is reporting an absence as a satisfied request.
     *
     * The render walk holds a resolution to that: a node that declares bindings and
     * gets `NO_DATA` with no problems beside it earns a `data-unresolved`
     * diagnostic, because the only way to be in that state honestly is to have been
     * built from a different tree's plan.
     */
    readonly lookup: (nodeId: NodeId) => NodeData;
    readonly problemsFor: (nodeId: NodeId) => readonly NodeDataProblem[];
}

EMPTY_DATA_RESOLUTIONvalue

The resolution of a plan that asks nothing, which is the only tree it is correct for.

const EMPTY_DATA_RESOLUTION: DataResolution

buildDataResolutionfunction

Folds a plan and its answers into the two lookups the walk uses. Separated from resolveDataPlan because it is pure: given the same answers it builds the same resolution, which is what makes the failure paths testable without a fake adapter for each one.

const buildDataResolution: (plan: DataPlan, answers: ReadonlyMap<RequestKey, Result<JsonValue, DataUnavailable>>) => DataResolution

Resolving a binding

data/resolve

The one step in serving a page that does IO.

ResolveDataOptionstype

type ResolveDataOptions = {
    readonly registry: DataRegistry;
    /** Passed through to every adapter — the render request's host context. */
    readonly context?: JsonObject;
    /**
     * How long any one source is given, each independently of the others. Defaults
     * to `DEFAULT_SOURCE_CEILING_MS`; there is no way to wait forever.
     */
    readonly ceilingMs?: number;
};

DEFAULT_SOURCE_CEILING_MSvalue

Ten seconds per source, which is already several times longer than a reader will wait for a page and generous for a query somebody has indexed.

const DEFAULT_SOURCE_CEILING_MS = 10000

resolveDataPlanfunction

const resolveDataPlan: (plan: DataPlan, options: ResolveDataOptions) => Promise<DataResolution>

resolveTreeDatafunction

Plan and resolve in one step, for the ordinary caller that does both.

Shown in use on Where the content comes from — Asking, before anything is drawn and When nothing comes back — Changing how long you are prepared to wait

const resolveTreeData: (tree: LoomTree, options: ResolveDataOptions) => Promise<DataResolution>

A source a node may name

data/source

What a data source is, from the tree's side of the seam.

SOURCE_ID_EXPECTATIONvalue

The grammar a source id has to satisfy, in words, said once.

const SOURCE_ID_EXPECTATION = "expected dot-namespaced kebab-case, like \"commerce.products\""

sourceIdSchemaschema

The identifier of a registered data source — the contract between a binding in the tree and whatever answers it.

const sourceIdSchema: z.ZodBranded<…>

SourceIdtype

type SourceId = z.infer<typeof sourceIdSchema>;

BINDING_NAME_EXPECTATIONvalue

The grammar a binding name has to satisfy, for the same reader.

const BINDING_NAME_EXPECTATION = "expected camelCase, like \"services\""

bindingNameSchemaschema

The name a primitive reads an answer under — loom.data.services. camelCase, mirroring slot names and prop names, because it is the same kind of thing: a named region of what a primitive receives.

const bindingNameSchema: z.ZodBranded<…>

BindingNametype

type BindingName = z.infer<typeof bindingNameSchema>;

Deadline

deadline

A ceiling on an await into somebody else's code.

ceilingOffunction

The ceiling a caller named, or the default when it named nothing usable.

const ceilingOf: (given: number | undefined, fallback: number) => number

describeCeilingfunction

250ms, 10s, 3m — a ceiling in the unit somebody would say it in.

const describeCeiling: (ms: number) => string

withCeilingfunction

Runs attempt with a ceiling, and answers with expired() if it is reached first.

const withCeiling: <T>(ceilingMs: number, expired: () => T, attempt: (signal: AbortSignal) => Promise<T>) => Promise<T>

Catalogue

frame/catalogue

Whose documents this deployment will frame, as a model is told it.

CataloguedFrameOrigintype

type CataloguedFrameOrigin = {
    readonly origin: FrameOrigin;
    /** One line, written by whoever runs the deployment. */
    readonly description: string;
};

FrameCataloguetype

type FrameCatalogue = readonly CataloguedFrameOrigin[];

frameCataloguefunction

const frameCatalogue: (registry: FrameOriginRegistry) => FrameCatalogue

Origin

frame/origin

The host's half of the framing seam: whose documents this deployment is willing to put inside an iframe.

frameOriginSchemaschema

A host-authored origin — scheme, host and port, and nothing else.

const frameOriginSchema: z.ZodBranded<…>

FrameOrigintype

type FrameOrigin = z.infer<typeof frameOriginSchema>;

FrameOriginDefinitiontype

What a deployment says about one origin it will frame.

type FrameOriginDefinition = {
    readonly origin: string;
    /** One line, for the catalogue — "Vimeo player embeds". */
    readonly description: string;
    /**
     * Whether this origin is the deployment's own.
     *
     * It changes nothing about whether a frame is permitted and everything about
     * what the frame is worth. A primitive's `sandbox` is only a sandbox at all
     * *because* the framed document is cross-origin: `allow-scripts` beside
     * `allow-same-origin` on a document from this very origin re-grants the
     * document full access to the page that framed it. Nothing in a render could
     * previously tell — this is the one place that can, because it is the only
     * place that knows what "own" means for this deployment.
     */
    readonly self?: boolean;
};

RegisteredFrameOrigintype

type RegisteredFrameOrigin = {
    readonly origin: FrameOrigin;
    readonly description: string;
    readonly self: boolean;
};

FrameOriginRegistryErrortype

type FrameOriginRegistryError = {
    readonly code: "invalid-frame-origin";
    readonly origin: string;
    readonly detail: string;
} | {
    readonly code: "duplicate-frame-origin";
    readonly origin: string;
};

describeFrameOriginRegistryErrorfunction

const describeFrameOriginRegistryError: (error: FrameOriginRegistryError) => string

FrameOriginRegistryinterface

interface FrameOriginRegistry {
    /** `undefined` when nothing registered covers the URL's origin. */
    readonly origin: (url: URL) => RegisteredFrameOrigin | undefined;
    /** In registration order, so a catalogue reads predictably. */
    readonly origins: readonly RegisteredFrameOrigin[];
}

createFrameOriginRegistryfunction

Building the registry is pure and reaches nothing. An allowlist is a static fact about a deployment — it does not vary by visitor, it is not minted per request, and there is nothing to await — which is why this seam has no plan and no resolve step, unlike the submission seam whose shape it otherwise borrows.

const createFrameOriginRegistry: (definitions: readonly FrameOriginDefinition[]) => Result<FrameOriginRegistry, FrameOriginRegistryError>

Resolution

frame/resolution

The verdict on a URL a page means to put inside a frame, and the shape a primitive reads it in.

FrameOutcometype

What a primitive is told about a URL it means to frame.

type FrameOutcome = {
    readonly status: "allowed";
    /**
     * The URL to place, normalised through `URL`. Normalised rather than
     * echoed so that what the browser resolves is what this seam checked —
     * a `src` that differed from the string the allowlist was matched against
     * would make the check advisory.
     */
    readonly url: string;
    readonly origin: FrameOrigin;
    /**
     * The framed document is this deployment's own, so the primitive's
     * `sandbox` grants it nothing: `allow-scripts` with `allow-same-origin`
     * is only a boundary between two origins. Permitted — a host that
     * registered its own origin meant to — and said out loud, in the render's
     * diagnostics and here, because it is the one thing about a frame that
     * looks safe and is not.
     */
    readonly sameOrigin: boolean;
} | {
    readonly status: "refused";
    readonly refusal: FrameRefusal;
};

FrameRefusaltype

Why a URL will not be framed. Each is something a primitive may be told about and a diagnostic says out loud; none of them is an exception anybody catches.

type FrameRefusal = {
    readonly reason: /** No allowlist was wired, so this deployment frames nothing. */ "no-registry" | /** Parsed, and nobody registered its origin. */ "unlisted-origin" | /** Not an absolute http(s) URL at all — so it has no origin to check. */ "unframeable";
    readonly detail: string;
};

describeFrameRefusalfunction

const describeFrameRefusal: (refusal: FrameRefusal) => string

resolveFramefunction

The whole check, as a pure function of a registry and a value.

const resolveFrame: (declared: JsonValue | undefined, registry: FrameOriginRegistry | undefined) => FrameOutcome

NodeFramestype

A node's frames, by the prop name that carried each one.

type NodeFrames = Readonly<Record<string, FrameOutcome | undefined>>;

NO_FRAMESvalue

const NO_FRAMES: NodeFrames

Grammar

grammar

The grammar of Loom's identifiers, as plain patterns and nothing else.

NAMESPACED_ID_PATTERNvalue

The grammar of every name a tree uses to reach something a deployment registered: a primitive, a data source, a submission endpoint. Dot-namespaced kebab-case — stack, commerce.product-card, contact.enquiry.

const NAMESPACED_ID_PATTERN: RegExp

Ids

ids

Identity scheme.

nodeIdSchemaschema

const nodeIdSchema: z.ZodBranded<…>

NodeIdtype

type NodeId = z.infer<typeof nodeIdSchema>;

treeIdSchemaschema

const treeIdSchema: z.ZodBranded<…>

TreeIdtype

type TreeId = z.infer<typeof treeIdSchema>;

deltaIdSchemaschema

const deltaIdSchema: z.ZodBranded<…>

DeltaIdtype

type DeltaId = z.infer<typeof deltaIdSchema>;

intentIdSchemaschema

const intentIdSchema: z.ZodBranded<…>

IntentIdtype

type IntentId = z.infer<typeof intentIdSchema>;

proposalIdSchemaschema

const proposalIdSchema: z.ZodBranded<…>

ProposalIdtype

type ProposalId = z.infer<typeof proposalIdSchema>;

IdFactoryinterface

Id minting is a side effect, so it enters the runtime through this seam rather than being called directly from pure code. Tests and replay tooling inject a deterministic factory; production injects the random one.

Shown in use on Your first tree — Ids, and why you pass a factory

interface IdFactory {
    readonly nodeId: () => NodeId;
    readonly treeId: () => TreeId;
    readonly deltaId: () => DeltaId;
    readonly intentId: () => IntentId;
    readonly proposalId: () => ProposalId;
}

randomIdFactoryvalue

Shown in use on Your first tree — Ids, and why you pass a factory, Connecting a model — The slot, and what goes in it, When nothing comes back — Changing how long you are prepared to wait and What your app has to do — The three things a change needs

const randomIdFactory: IdFactory

sequentialIdFactoryfunction

Deterministic ids for tests and for replaying a recorded session. Counters are per-kind so a tree and its deltas read as n_1, n_2 … d_1, d_2. The namespace keeps two independent factories from minting the same id — a test that builds nodes for an existing tree passes one.

Shown in use on Your first tree and Starting from a band — Ask for one by name

const sequentialIdFactory: (namespace?: string) => IdFactory

Which primitives are targets

interactivity

What a primitive says about whether it renders a target the reader aims at.

InteractiveWhentype

The conditional case is the common one, and collapsing it into always would make the check worse than useless: a loom.card with no href holding a loom.action is the ordinary composition, and a rule that refused it is a rule hosts turn off.

type InteractiveWhen = "always" | {
    readonly whenProps: readonly string[];
};

interactiveWhenSchemaschema

const interactiveWhenSchema: z.ZodType<…>

InteractiveTypestype

The primitives of a deployment that render a target, by type.

type InteractiveTypes = Readonly<Record<string, InteractiveWhen | undefined>>;

interactiveTypesSchemaschema

const interactiveTypesSchema: z.ZodType<…>

isInteractiveWithfunction

The own-property test is load-bearing rather than belt-and-braces: a trigger named constructor or toString would otherwise read a function off Object.prototype and make every node of that type a target.

const isInteractiveWith: (when: InteractiveWhen, props: Readonly<Record<string, unknown>>) => boolean

Client

interpretation/client

The network boundary, and nothing else.

ModelEfforttype

type ModelEffort = "low" | "medium" | "high" | "xhigh" | "max";

ModelRequesttype

type ModelRequest = {
    readonly model: string;
    readonly maxTokens: number;
    readonly effort: ModelEffort;
    readonly system: string;
    readonly userMessage: string;
    /** JSON Schema the reply is constrained to. */
    readonly outputSchema: JsonObject;
};

ModelCallOptionstype

How the call is being made, as distinct from what is being asked.

type ModelCallOptions = {
    readonly signal?: AbortSignal;
};

ModelCompletiontype

type ModelCompletion = {
    /** The reply body, expected to be JSON matching `outputSchema`. */
    readonly text: string;
    /** The model that actually served the request, which may differ from the one asked for. */
    readonly servedBy: string;
};

ModelClientErrortype

Five failure modes, kept apart because each names a different actor — the one who would have to do something for the next attempt to go differently.

type ModelClientError = {
    readonly code: "unavailable";
    readonly detail: string;
} | {
    readonly code: "rejected";
    readonly detail: string;
} | {
    readonly code: "misconfigured";
    readonly detail: string;
} | {
    readonly code: "refused";
    readonly detail: string;
} | {
    readonly code: "incomplete";
    readonly detail: string;
};

ModelClientinterface

An implementation is allowed to take as long as it likes; it is not allowed to decide how long the runtime waits. modelInterpreter puts a ceiling on every call it makes, so a client that hangs is reported as unavailable rather than becoming a page that never finishes loading — which is true of a host's own client as much as of the Anthropic adapter.

Shown in use on Connecting a model — The slot, and what goes in it

interface ModelClient {
    readonly complete: (request: ModelRequest, options?: ModelCallOptions) => Promise<Result<ModelCompletion, ModelClientError>>;
}

Draft

interpretation/draft

What a model is allowed to say.

draftPropsSchemaschema

A JSON-encoded object of props: {"tone":"quiet","count":2}.

const draftPropsSchema: z.ZodString

DraftNodetype

type DraftNode = {
    readonly kind: "element";
    readonly type: PrimitiveType;
    readonly props: string;
    readonly children: readonly DraftNode[];
} | {
    readonly kind: "text";
    readonly value: string;
};

draftNodeSchemaschema

slot is absent on purpose. A slot is a projection region a primitive declares, not content an edit adds — §4's catalogue tells a model which slots a primitive has, and slots already in a tree stay addressable, configurable, and projectable. Dropping the third variant is also part of what keeps depth 4 affordable.

const draftNodeSchema: z.ZodType<…>

draftOperationSchemaschema

const draftOperationSchema: z.ZodDiscriminatedUnion<…>

DraftOperationtype

type DraftOperation = z.infer<typeof draftOperationSchema>;

interpretationReplySchemaschema

The reply is a three-way outcome rather than a delta, because "I understood you and nothing needs to change" and "I did not understand you" are answers, not failures to answer. Both map onto an InterpretationError code; neither is a rejected proposal.

const interpretationReplySchema: z.ZodDiscriminatedUnion<…>

InterpretationReplytype

type InterpretationReply = z.infer<typeof interpretationReplySchema>;

Interpreter

interpretation/interpreter

A ChangeInterpreter backed by a model.

DEFAULT_INTERPRETER_MODELvalue

const DEFAULT_INTERPRETER_MODEL = "claude-opus-5"

DEFAULT_MAX_TOKENSvalue

const DEFAULT_MAX_TOKENS = 16000

DEFAULT_EFFORTvalue

const DEFAULT_EFFORT: ModelEffort

DEFAULT_INTERPRETER_CEILING_MSvalue

Three minutes, which is long enough that no interpretation this repository has measured comes near it and short enough that a request nobody is ever going to answer is reported while somebody is still looking at the screen.

const DEFAULT_INTERPRETER_CEILING_MS = 180000

ModelInterpreterConfigtype

Everything wired once per deployment: how to reach a model, and what to tell it about this deployment. The second half is PromptVocabularies, composed rather than restated so that a vocabulary added there reaches a host's wiring and the prompt in the same change.

type ModelInterpreterConfig = PromptVocabularies & {
    readonly client: ModelClient;
    readonly idFactory: IdFactory;
    readonly clock: Clock;
    readonly model?: string;
    readonly maxTokens?: number;
    readonly effort?: ModelEffort;
    /**
     * How long to wait for a reply before reporting that there was not one.
     * Defaults to `DEFAULT_INTERPRETER_CEILING_MS`; there is no way to wait
     * forever, deliberately.
     */
    readonly ceilingMs?: number;
    readonly draftDepth?: number;
};

modelInterpreterfunction

Implements both seams. Whether a deployment actually repairs is decided by whether this object is also wired in as the runtime's repairer — the capability is offered here, but never assumed.

Shown in use on Proposing a change — Who writes the plan, Connecting a model — The slot, and what goes in it and When nothing comes back — Changing how long you are prepared to wait

const modelInterpreter: (config: ModelInterpreterConfig) => ChangeInterpreter & ChangeRepairer

Materialize

interpretation/materialize

The inverse of the projection in draft.ts: a validated draft becomes a real TreeDelta. This is where the runtime, not the proposer, decides identity — every inserted node gets its id here, from the same seam the rest of the system mints through.

MaterializeContexttype

type MaterializeContext = {
    readonly treeId: TreeId;
    readonly baseRevision: number;
    readonly deltaId: DeltaId;
    readonly idFactory: IdFactory;
};

decodePropsfunction

Props arrive as one JSON-encoded object. This is the whole of the trust boundary for them: parse it, require an object rather than an array or a scalar, and validate every value against the JSON value space so nothing un-serialisable reaches a tree.

const decodeProps: (encoded: string) => Result<JsonObject, string>

materializeDeltafunction

const materializeDelta: (operations: readonly DraftOperation[], context: MaterializeContext) => Result<TreeDelta, string>

Prompt

interpretation/prompt

Prompt assembly, kept pure so the exact bytes sent to a model are a value a test can assert on rather than a side effect of calling one.

INTERPRETER_SYSTEM_PROMPTvalue

const INTERPRETER_SYSTEM_PROMPT = "You translate a request about a user interface into discrete edits to a component tree.\n\nThe tree is given as an indented outline. Every line starts with a node id. There are three node kinds:\n- element \u2014 an instance of a primitive, with props and ordered children\n- text \u2014 a leaf string\n- slot \u2014 a named region whose children are fallback content\n\nYou reply with one of three outcomes:\n- \"change\" \u2014 the intent is satisfiable, and you give the operations that satisfy it\n- \"no-change\" \u2014 you understood the intent and the tree already satisfies it\n- \"not-understood\" \u2014 the intent is ambiguous, or asks for something the operations cannot express\n\nThere are exactly four operations:\n- insert \u2014 a new node at a position under an existing parent\n- remove \u2014 an existing node and its whole subtree\n- move \u2014 an existing node to a new parent and position\n- configure \u2014 set or unset props on an existing node\n\nRules you must follow:\n- Address existing nodes only by ids copied exactly from the outline. Never invent an id.\n- Never give an id to a node you are inserting. The runtime assigns it.\n- Operations apply in order, and each one sees the effects of the ones before it.\n- move is a detach followed by an insert, so its index counts positions in the child list the node has already left.\n- props is the node's props as a JSON object, JSON-encoded into a string: \"{\"tone\":\"quiet\",\"count\":2}\". Use \"{}\" for no props. configure's set is written the same way. Values are ordinary JSON \u2014 strings, numbers, booleans, null, arrays, objects \u2014 with no tags or wrappers.\n- You may insert element and text nodes. You cannot insert a slot: a slot is a region a primitive declares, not content an edit adds. Slots already in the tree can be configured, moved into, and inserted into like anything else.\n- Propose the smallest set of operations that satisfies the intent. Do not tidy, restructure, or improve anything you were not asked about.\n- Reuse primitive types already present in the tree unless the intent clearly calls for a new one.\n- confidence is your own estimate that these operations satisfy the intent, from 0 to 1. Report it honestly; a low number is more useful than a wrong high one.\n\nSometimes you will be shown a proposal that was refused, and the reason. You get one revision, and the rules above still apply. A revision must be a more conservative way to satisfy the same request \u2014 smaller in what it destroys, or narrower in what it touches. It must not be the same change split into a smaller piece so that the remainder can be asked for again: if the request cannot be satisfied within the stated objection, say \"not-understood\" and explain what would have to change. Reproposing what was already refused, or working around the objection rather than respecting it, is the one thing a revision must never do."

PromptVocabulariestype

Everything a deployment can offer a model, in one value.

type PromptVocabularies = {
    /**
     * What this deployment can build with. Absent means the model is told only
     * what the tree shows, which is what §2 shipped with; a host that has a §4
     * registry projects it with `catalogueOf` and the model stops guessing at
     * primitive names it has no way to know.
     */
    readonly catalogue?: PrimitiveCatalogue;
    /**
     * What this deployment may be themed with. Absent means the model is shown no
     * theme vocabulary and cannot re-theme anything: the ids in the tree are the
     * only ones it knows, so "make it warmer" has nowhere to go but an invented
     * id that fails to resolve. A host with a §4b theme registry passes
     * `themes.catalogue()`.
     */
    readonly themeCatalogue?: ThemeCatalogue;
    /**
     * What this deployment can be asked about. Absent means the model is told
     * nothing about the data seam, so the only bindings it can propose are ones
     * it invented — refused at the seam as `no-such-source`, which is the right
     * refusal for a guess nobody gave it the information to avoid. A host
     * with a source registry passes `dataCatalogue(registry)`.
     */
    readonly dataCatalogue?: DataCatalogue;
    /**
     * Where this deployment will accept a submission. Absent means the model
     * cannot point a form anywhere, which is the safe default rather than a gap:
     * `loom:submit` names a registered endpoint and nothing else leaves the tree
     *. A host with an endpoint registry passes
     * `submissionCatalogue(registry)`.
     */
    readonly submissionCatalogue?: SubmissionCatalogue;
    /**
     * Whose documents this deployment will frame. Absent means the model is not
     * told, and a frame it proposes renders a refusal unless it happened to name
     * a registered origin. Unlike the four above this one narrows a value the
     * model still chooses, because which video belongs on a page is a content
     * decision and the URL stays in the tree.
     */
    readonly frameCatalogue?: FrameCatalogue;
};

buildUserMessagefunction

The five vocabulary blocks lead, then the tree, then the sentence somebody typed.

const buildUserMessage: (intent: EditIntent, tree: LoomTree, vocabularies?: PromptVocabularies) => string

PromptMeasurementtype

What one proposal request costs, block by block, in characters.

type PromptMeasurement = {
    readonly system: number;
    readonly primitives: number;
    readonly themes: number;
    readonly sources: number;
    readonly endpoints: number;
    readonly frames: number;
    readonly tree: number;
    readonly request: number;
    /** The sum of the eight, and what actually goes over the wire. */
    readonly total: number;
};

measurePromptfunction

Measures a request without sending it.

Shown in use on Connecting a model — What one request costs

const measurePrompt: (intent: EditIntent, tree: LoomTree, vocabularies?: PromptVocabularies) => PromptMeasurement

CataloguedCosttype

What one registered primitive costs on every request that names it.

type CataloguedCost = {
    readonly type: PrimitiveType;
    readonly characters: number;
};

CatalogueCosttype

What a deployment's vocabulary costs, whole and per entry.

type CatalogueCost = {
    readonly entries: number;
    /** The block as sent, the instruction around the list included. */
    readonly characters: number;
    /** The mean cost of one entry, rounded. `0` for a catalogue with no entries. */
    readonly perEntry: number;
    /** What each entry costs, in catalogue order. */
    readonly byType: readonly CataloguedCost[];
};

measureCataloguefunction

Measures a vocabulary without sending it.

const measureCatalogue: (catalogue: PrimitiveCatalogue) => CatalogueCost

buildRepairMessagefunction

const buildRepairMessage: (request: RepairRequest, tree: LoomTree, vocabularies?: PromptVocabularies) => string

RepairMeasurementtype

What a second go costs, once the first one was refused.

type RepairMeasurement = {
    readonly proposal: PromptMeasurement;
    /** The refused delta, rendered, and the reasoning offered for it. */
    readonly refused: number;
    /** The Gate's reason code and its detail. */
    readonly objection: number;
    /** The closing sentence that makes this a revision rather than a fresh ask. */
    readonly instruction: number;
    /** `proposal.total` plus the three above: what the repair puts on the wire. */
    readonly total: number;
    /** `proposal.total + total`: the whole refused-then-repaired episode. */
    readonly episode: number;
};

measureRepairPromptfunction

Measures a repair without sending it, on the same terms as measurePrompt.

const measureRepairPrompt: (request: RepairRequest, tree: LoomTree, vocabularies?: PromptVocabularies) => RepairMeasurement

hashPromptfunction

Provenance records a hash rather than the prompt, so an audit trail can prove two proposals came from the same question without storing what was asked.

const hashPrompt: (system: string, userMessage: string) => Promise<string>

Render

interpretation/render

The tree as the model sees it.

renderTreefunction

A scoped request sends the scope, not the page it sits on.

const renderTree: (tree: LoomTree, scopeNodeId?: NodeId) => string

renderCataloguefunction

The catalogue as the model sees it: one line per primitive, in registration order, so the deployment's own ordering is what the model reads first.

const renderCatalogue: (catalogue: PrimitiveCatalogue) => string

renderThemeCataloguefunction

The theme vocabulary as the model sees it — three lists of ids with the sentence their author wrote about each.

const renderThemeCatalogue: (catalogue: ThemeCatalogue) => string

renderSourceCataloguefunction

The sources as the model sees it — one line per registered source, with the params it declares written the way a primitive's props are.

const renderSourceCatalogue: (catalogue: DataCatalogue) => string

renderSubmissionCataloguefunction

The endpoints as the model sees it — an id and a line, and deliberately nothing else.

const renderSubmissionCatalogue: (catalogue: SubmissionCatalogue) => string

renderFrameCataloguefunction

The framable origins as the model sees it — scheme, host and port, and the sentence whoever runs the deployment wrote about each.

const renderFrameCatalogue: (catalogue: FrameCatalogue) => string

renderDeltafunction

const renderDelta: (delta: TreeDelta) => string

Schema

interpretation/schema

The JSON Schema handed to the model, hand-written rather than derived from the Zod draft schema, because structured output accepts a strict subset of JSON Schema: every object must close over its properties, and recursive definitions are not allowed.

DEFAULT_DRAFT_DEPTHvalue

const DEFAULT_DRAFT_DEPTH = 4

GRAMMAR_BUDGET_BYTESvalue

The size guard, in bytes of serialised schema.

const GRAMMAR_BUDGET_BYTES = 3500

interpretationReplyJsonSchemafunction

const interpretationReplyJsonSchema: (depth?: number) => JsonObject

draftSchemaByteSizefunction

The serialised size of the emitted schema, which is what the budget is asserted against offline. Bytes rather than characters: the description strings are the part most likely to grow, and the part most likely to grow a non-ASCII character.

const draftSchemaByteSize: (depth?: number) => number

JSON, narrowly defined

json

The JSON floor the rest of the runtime stands on.

JsonValuetype

Everything that crosses a Loom boundary — the tree, deltas, telemetry — must round-trip through JSON without loss. Props are therefore restricted to JSON values: no functions, no Dates, no undefined, no class instances.

type JsonValue = string | number | boolean | null | JsonValue[] | {
    [key: string]: JsonValue;
};

jsonValueSchemaschema

const jsonValueSchema: z.ZodType<…>

JsonObjecttype

type JsonObject = {
    [key: string]: JsonValue;
};

JsonObjectViewtype

A narrower view of a JsonObject — what a schema declares it needs, rather than everything a stored object may hold. Optional members are allowed to be undefined because an absent key in a JsonObject reads that way; the value space itself is unchanged, since undefined never survives serialisation and so can never be a stored prop.

type JsonObjectView = {
    [key: string]: JsonValue | undefined;
};

jsonObjectSchemaschema

const jsonObjectSchema: z.ZodType<…>

Paging

paging

Keyset paging, as a rule rather than a convention.

LimitBoundstype

type LimitBounds = {
    /** Used when the caller named no limit, or named one that is not a number. */
    readonly fallback: number;
    readonly max: number;
};

clampLimitfunction

const clampLimit: (limit: number | undefined, bounds: LimitBounds) => number

PageDirectiontype

Which end of an append-only sequence a page is taken from.

type PageDirection = "newer" | "older";

PageEndstype

The two ends a page names: cursors to resume from in either direction, null when the page already reaches that end.

type PageEnds = {
    readonly older: string | null;
    readonly newer: string | null;
};

pageEndsfunction

The ends a page reports, given the positions it holds and what the read already knows.

const pageEnds: (positions: readonly number[], direction: PageDirection, { beyond, resumed }: {
    readonly beyond: boolean;
    readonly resumed: boolean;
}) => PageEnds

cursorPositionfunction

Reads a cursor back into the position it names. A cursor is opaque to the caller, so one that is absent or unreadable means the same thing to every implementation — start at the end the direction begins from — rather than one refusing what another silently accepts.

const cursorPosition: (cursor: string | undefined) => number | undefined

Primitive names

primitive-type

The names a tree uses to reach what a deployment registered.

primitiveTypeSchemaschema

The identifier of a registered primitive — the contract between an element node and whatever renders it. The tree schema only cares about the shape of the identifier; resolving it to an implementation is the registry's job, which keeps the AST independent of any particular primitive library.

const primitiveTypeSchema: z.ZodBranded<…>

PrimitiveTypetype

type PrimitiveType = z.infer<typeof primitiveTypeSchema>;

slotNameSchemaschema

A named region inside a primitive that accepts projected children. camelCase, mirroring prop naming.

const slotNameSchema: z.ZodBranded<…>

SlotNametype

type SlotName = z.infer<typeof slotNameSchema>;

textKeySchemaschema

A key naming one of the strings a primitive owns. camelCase, mirroring slot and prop naming — and, load-bearing, never containing a dot: a dictionary addresses a string as ${type}.${key}, and a key with a dot in it would make that address ambiguous between two primitives.

const textKeySchema: z.ZodBranded<…>

TextKeytype

type TextKey = z.infer<typeof textKeySchema>;

The loom: namespace

reserved-props

The runtime's own corner of a node's props.

RESERVED_PROP_PREFIXvalue

Prop keys under this prefix belong to the runtime rather than to a primitive.

const RESERVED_PROP_PREFIX = "loom:"

THEME_PROP_KEYvalue

The palette, font pack and style preset a tree names, honoured on the root node.

const THEME_PROP_KEY = "loom:theme"

DATA_PROP_KEYvalue

A node's bindings — what it asks the host to answer, honoured on any node.

const DATA_PROP_KEY = "loom:data"

SUBMIT_PROP_KEYvalue

A node's submission — which registered endpoint a form posts to, on any node.

const SUBMIT_PROP_KEY = "loom:submit"

ANCHOR_PROP_KEYvalue

The fragment a node answers to, so the page's own links can point at it, on any node.

const ANCHOR_PROP_KEY = "loom:anchor"

isReservedPropKeyfunction

const isReservedPropKey: (key: string) => boolean

PartitionedPropstype

type PartitionedProps = {
    /** What the primitive and the validator see. */
    readonly props: JsonObject;
    /** What the runtime reads, keyed as it appears in the tree. */
    readonly reserved: JsonObject;
};

partitionReservedPropsfunction

Splitting costs one pass over the keys, and allocates nothing at all for the ordinary node that carries no reserved key — which is most of them.

const partitionReservedProps: (props: JsonObject) => PartitionedProps

Results, and the errors they carry

result

Loom never throws across a module seam. Every fallible operation returns a Result so callers must acknowledge failure in the type system.

Resulttype

Either a value or an error, and the type says which until the caller checks.

Shown in use on Installation — TypeScript, Rendering a tree — Serving a page, rather than rendering a value, Proposing a change — Who writes the plan, Connecting a model — The slot, and what goes in it and What your readers do — Two: start the broadcaster, in the browser

type Result<TValue, TError> = {
    readonly ok: true;
    readonly value: TValue;
} | {
    readonly ok: false;
    readonly error: TError;
};

okfunction

Shown in use on Installation — TypeScript, Where the content comes from — Your half: registering a source, When nothing comes back — The half that is not about the answer and What a form posts to — Your half: registering an endpoint

const ok: <TValue>(value: TValue) => Result<TValue, never>

errfunction

const err: <TError>(error: TError) => Result<never, TError>

mapResultfunction

const mapResult: <TValue, TNext, TError>(result: Result<TValue, TError>, transform: (value: TValue) => TNext) => Result<TNext, TError>

flatMapResultfunction

const flatMapResult: <TValue, TNext, TError>(result: Result<TValue, TError>, transform: (value: TValue) => Result<TNext, TError>) => Result<TNext, TError>

reduceResultfunction

Folds a sequence of fallible steps over an accumulator, short-circuiting on the first error. This is how atomic multi-operation deltas are applied.

const reduceResult: <TItem, TAccumulator, TError>(items: readonly TItem[], initial: TAccumulator, step: (accumulator: TAccumulator, item: TItem, index: number) => Result<TAccumulator, TError>) => Result<TAccumulator, TError>

assertNeverfunction

Exhaustiveness guard for discriminated unions. Reaching it is a type error at compile time and an explicit failure at runtime.

const assertNever: (value: never, context: string) => never

Role

role

What part a primitive plays, said by the author who knows and asked by every consumer that needs a semantic fact about a component rather than its name.

PrimitiveRoletype

The vocabulary, and it has one member on purpose.

type PrimitiveRole = "heading";

PRIMITIVE_ROLESvalue

Every role, so a host can render the vocabulary rather than keep its own copy of it — the same reason every other closed set in this package is exported.

const PRIMITIVE_ROLES: readonly PrimitiveRole[]

describePrimitiveRolefunction

One line per role, for a host putting the vocabulary in front of a person.

const describePrimitiveRole: (role: PrimitiveRole) => string

isPrimitiveRolefunction

Whether a string a host wrote is a role the runtime knows.

const isPrimitiveRole: (value: string) => value is PrimitiveRole

Analysis

runtime/analysis

Facts about what a delta does, extracted before anyone judges it.

ChangeAnalysistype

type ChangeAnalysis = {
    readonly operationCount: number;
    readonly insertedNodeCount: number;
    readonly removedNodeCount: number;
    /** Nodes a move operation named. The subtree each carried is counted separately. */
    readonly movedNodeCount: number;
    readonly configuredNodeCount: number;
    /**
     * Nodes a move carried, the node it named included. A move is the one
     * operation whose reach is larger than the node it names, and measuring it
     * that way is what makes the analysis independent of how the delta was
     * phrased: relocating a slot and relocating the card inside it are the same
     * physical change described two ways.
     */
    readonly relocatedNodeCount: number;
    /** Nodes directly touched. A move counts the node, not the subtree riding along. */
    readonly affectedNodeIds: readonly NodeId[];
    /**
     * Types this delta created, destroyed, or reconfigured. A move contributes
     * nothing here whichever node it names — a relocated node is not rewritten,
     * and `relocatedPrimitiveTypes` is where it is reported instead.
     */
    readonly touchedPrimitiveTypes: readonly PrimitiveType[];
    /**
     * Types carried by a move, in the whole subtree rather than at its root. A
     * protected primitive travelling across the page is a fact the Gate has to
     * see, and it is not the same fact as one being rewritten.
     */
    readonly relocatedPrimitiveTypes: readonly PrimitiveType[];
    /**
     * Types destroyed outright, a subset of the touched types. Destroying a
     * primitive is a strictly bigger deal than reconfiguring one, so stakes need
     * to tell the two apart.
     */
    readonly removedPrimitiveTypes: readonly PrimitiveType[];
    readonly configuredPropKeys: readonly string[];
    /**
     * Targets this change leaves inside another target, which is a control the
     * reader cannot use. Sometimes because the markup is invalid and a browser
     * drops the inner link; sometimes because the enclosing node covers itself
     * with an overlay and the click never arrives. Same damage either way,
     * and only the outer node's own declaration distinguishes it.
     *
     * Measured on the resulting tree rather than on the operations, because every
     * operation kind can produce it and only one of them looks like it does:
     * `insert` and `move` put a target somewhere, and `configure` breaks every
     * link already inside a card by giving the card an `href`. Positions the tree
     * already had are excluded, so a change is answerable for the breakage it
     * introduces and not for the breakage it inherited.
     *
     * Empty for every host that declares no interactive vocabulary, which is the
     * default.
     */
    readonly nestedTargets: readonly NestedTarget[];
    /**
     * Nodes this change would add that the deployment has no primitive for, so
     * the page it produces has a hole where each one is.
     *
     * Measured on the operations rather than on the resulting tree, unlike
     * `nestedTargets`, and the difference is the point. A tree may already name a
     * primitive a later deployment rolled back — that is exactly why the renderer
     * reports instead of throwing — and a change is answerable for the
     * holes it introduces, not for the ones it found. Only `insert` can introduce
     * one: `move` carries nodes the tree already had, and `configure` cannot
     * change a type.
     *
     * Empty for every host that declares no vocabulary, which is the default.
     */
    readonly unknownPrimitives: readonly UnknownPrimitive[];
    /**
     * Nodes this change would leave carrying props the primitive declaring their
     * type refuses, so the page it produces has a hole where each one is — the
     * same hole `unknownPrimitives` describes, one question further down.
     *
     * Measured on both trees, like `nestedTargets`, and unlike `unknownPrimitives`:
     * props are the one thing two operations in a delta can argue about, so only
     * the tree at the end says what a reader will actually be served.
     * `introducedInvalidProps` says why, and what a node already failing before
     * the change counts as.
     *
     * Empty for every host that wires no props vocabulary, which is the default.
     */
    readonly invalidProps: readonly InvalidProps[];
    /**
     * Questions this change would leave on the page that no primitive will look
     * at — a node asking under a name its own primitive says it does not read.
     *
     * The one fact in this record about a change that *works*. The page draws,
     * every node is registered, every schema is satisfied; the host pays a round
     * trip to its own source on every render and the region shows its empty state,
     * because the answer arrives under a name nobody opens.
     *
     * Measured on both trees, like `invalidProps` and unlike `unknownPrimitives`,
     * and for that factor's reason twice over: a `configure` can put `loom:data` on
     * a node that had none, and a `configure` can rename the *prop* that names the
     * binding, so two operations in one delta can argue about it and only
     * the tree at the end says what a reader will be served.
     *
     * Keyed by node **and name** where `invalidProps` is keyed by node alone, and
     * the difference is the harm rather than an oversight. A node whose props fail
     * is a hole, and a page has one hole there however many ways it is wrong; a
     * node asking three questions nothing reads is three wasted round trips, and a
     * change that adds the third is answerable for the third.
     *
     * Empty for every host that hands no reader, which is the default.
     */
    readonly unreadBindings: readonly UnreadBinding[];
    /**
     * Forms this change points somewhere else — a node that posted to one
     * registered endpoint before and posts to another after.
     *
     * Measured
…

Cut short here — the whole of it is longer than a page can usefully hold.

analyzeDeltafunction

Walks the delta forward so each operation is measured against the tree it actually observes — an operation may target a node an earlier operation in the same delta inserted.

const analyzeDelta: (tree: LoomTree, delta: TreeDelta, isInteractive?: InteractivePredicate, isRegistered?: PrimitiveVocabulary, checkProps?: PropsVocabulary, reads?: BindingReader) => Result<ChangeAnalysis, TreeError>

What the Gate weighed

runtime/assessment

Everything the Gate is allowed to look at, gathered in one pass. Assembling this is the last step that touches the tree; the Gate itself sees only this record and the policy, which is what keeps it a pure function of its inputs.

ChangeAssessmenttype

type ChangeAssessment = {
    readonly proposal: ProposedChange;
    readonly analysis: ChangeAnalysis;
    readonly stakes: StakeAssessment;
    readonly reversibility: Reversibility;
};

assessChangefunction

const assessChange: (tree: LoomTree, proposal: ProposedChange, policy: GatePolicy, inverseDeltaId: DeltaId, checkProps?: PropsVocabulary, reads?: BindingReader) => Result<ChangeAssessment, TreeError>

Disposition

runtime/disposition

What the Gate decided, and why. The rationale is structured rather than prose so that a host can render it, a test can assert on it, and telemetry can aggregate it — the same decision has to serve all three.

dispositionKindSchemaschema

const dispositionKindSchema: z.ZodEnum<…>

DispositionKindtype

type DispositionKind = z.infer<typeof dispositionKindSchema>;

dispositionReasonCodeSchemaschema

const dispositionReasonCodeSchema: z.ZodEnum<…>

DispositionReasonCodetype

type DispositionReasonCode = z.infer<typeof dispositionReasonCodeSchema>;

dispositionReasonSchemaschema

const dispositionReasonSchema: z.ZodObject<…>

DispositionReasontype

type DispositionReason = {
    readonly code: DispositionReasonCode;
    readonly detail: string;
};

UNATTRIBUTED_POLICY_IDvalue

What a disposition says when it was decided before the Gate recorded which policy decided it. Not the default policy's name: those judgments were made under a policy nobody wrote down, and claiming they were made under this host's current one would be a guess presented as a record.

const UNATTRIBUTED_POLICY_ID = "unattributed"

dispositionSchemaschema

The schema exists because a disposition outlives the process that decided it: §6 stores one and reads it back later, and a judgment restored from storage is validated at that boundary like anything else that crossed a wire or a year.

const dispositionSchema: z.ZodObject<…>

Dispositiontype

type Disposition = {
    readonly kind: DispositionKind;
    readonly reason: DispositionReason;
    readonly stakes: StakeLevel;
    readonly reversible: boolean;
    readonly confidence: number;
    readonly policyId: string;
    /** Absent on a judgment recorded before the Gate fingerprinted policies. */
    readonly policyFingerprint?: string;
    /**
     * Absent when nothing fired, and on a judgment recorded before the field
     * existed. The schema above says which absence is which.
     */
    readonly irreversibilityReasons?: readonly IrreversibilityReason[];
};

Events

runtime/events

The runtime narrates itself.

RuntimeEventtype

type RuntimeEvent = {
    readonly type: "intent-received";
    readonly intent: EditIntent;
}
/**
 * Which policy this intent will be judged under, settled before anything is
 * interpreted. Emitted even for an intent that never reaches a disposition,
 * because "which policy was in force" is a question about the ask rather than
 * about the answer — and an interpretation failure under a strict policy and
 * one under a lax policy are not the same event.
 *
 * It carries the whole policy: within a request the next stage needs the
 * values, not the name. What survives is narrower.
 */
 | {
    readonly type: "policy-resolved";
    readonly intentId: IntentId;
    readonly policy: GatePolicy;
} | {
    readonly type: "interpretation-failed";
    readonly intent: EditIntent;
    readonly error: InterpretationError;
} | {
    readonly type: "change-proposed";
    readonly proposal: ProposedChange;
} | {
    readonly type: "assessment-failed";
    readonly proposal: ProposedChange;
    readonly error: TreeError;
} | {
    readonly type: "change-assessed";
    readonly assessment: ChangeAssessment;
} | {
    readonly type: "disposition-decided";
    readonly proposalId: ProposalId;
    readonly disposition: Disposition;
}
/**
 * A refusal was handed back for one more attempt. Emitted *after* the
 * refusal's own `disposition-decided`, never instead of it: a change that was
 * refused and then repaired into something acceptable must leave both halves
 * of that story in the record, or the pattern becomes invisible.
 */
 | {
    readonly type: "repair-requested";
    readonly refusedProposalId: ProposalId;
    readonly reason: DispositionReason;
} | {
    readonly type: "repair-failed";
    readonly refusedProposalId: ProposalId;
    readonly error: InterpretationError;
} | {
    readonly type: "change-applied";
    readonly proposalId: ProposalId;
    readonly revision: number;
    readonly inverse: TreeDelta;
} | {
    readonly type: "application-failed";
    readonly proposalId: ProposalId;
    readonly error: TreeError;
}
/**
 * An intent that never reached interpretation, because the tree it named had
 * already moved on. Narrated rather than silently returned: a rising rate of
 * this is contention, and it is the one failure a client is expected to
 * recover from on its own.
 */
 | {
    readonly type: "intent-not-writable";
    readonly intent: EditIntent;
    readonly error: StoreError;
}
/**
 * The Gate held a change back and custody of it succeeded, so there is
 * something for a human to answer. Distinct from the `disposition-decided`
 * that preceded it: one is a judgment, the other is a proposal that still
 * exists to be confirmed.
 */
 | {
    readonly type: "proposal-held";
    readonly proposalId: ProposalId;
}
/**
 * Custody failed, so a change the Gate was willing to offer is simply gone.
 * Emitted because the alternative is a change that disappears between two
 * events that both say things went well.
 */
 | {
    readonly type: "hold-failed";
    readonly proposalId: ProposalId;
    readonly detail: string;
}
/**
 * A human answered a held proposal. The disposition that follows is the Gate's
 * second look.
 *
 * `actor` is who answered, which is not who asked: a hold exists precisely
 * because the Gate wanted a second person, and provenance records only the
 * first. This event is the only place the approval is attributed, so a
 * host that drops it keeps the change and loses who allowed it.
 */
 | {
    readonly type: "hold-confirmed";
    readonly proposalId: ProposalId;
    readonly actor?: string;
}
/**
 * A human answered no. The most valuable event in this list for §6: it is the
 * only one that says a change the Gate was prepared to allow was not wanted,
 * which is what calibration has to learn from — and a refusal is only
 * evidence about a reviewer if it says which one.
 */
 | {
    readonly type: "hold-discarded";
    readonly proposalId: ProposalId;
    readonly actor?: string;
}
/**
 * The delta reached the log. `change-applied` says a tree in memory accepted
 * it; this says the truth moved.
 */
 | {
    readonly type: "change-committed";
    readonly proposalId: ProposalId;
    readonly revision: number;
}
/**
 * Accepted, applied in memory, and then not persisted. The gap between
 * `change-applied` and this is the one place the event stream could lie about
 * what a tree contains, so it is narrated rather than returned only to the
 * caller.
 */
 | {
    readonly type: "commit-failed";
    readonly proposalId: ProposalId;
    readonly error: StoreError;
};

RuntimeEventEnvelopetype

type RuntimeEventEnvelope = {
    readonly treeId: TreeId;
    readonly occurredAt: string;
    readonly event: RuntimeEvent;
};

EventSinkinterface

Emission is fire-and-forget: a sink that throws cannot fail a change the Gate already accepted.

interface EventSink {
    readonly emit: (envelope: RuntimeEventEnvelope) => void;
}

Clockinterface

Reading the clock is a side effect, so it enters through a seam like ids do.

interface Clock {
    readonly now: () => string;
}

systemClockvalue

Shown in use on Starting from a band — Nothing gets to skip the Gate, Connecting a model — The slot, and what goes in it, When nothing comes back — Changing how long you are prepared to wait and What your app has to do — The three things a change needs

const systemClock: Clock

noopEventSinkvalue

const noopEventSink: EventSink

Gate

runtime/gate

The Gate.

EscalationCodetype

The code a rung stamps when it fires. Every reason code but the acceptance.

type EscalationCode = Exclude<DispositionReasonCode, "within-policy">;

ESCALATION_LADDERvalue

The ladder, in the order it is consulted, read off the rules themselves.

const ESCALATION_LADDER: readonly EscalationCode[]

gatefunction

const gate: (assessment: ChangeAssessment, policy: GatePolicy) => Disposition

What was asked for

runtime/intent

An EditIntent is what someone — or something — wants, before any interpretation. It is deliberately unstructured: a sentence, plus enough context to interpret it against a specific tree at a specific revision.

intentOriginSchemaschema

const intentOriginSchema: z.ZodEnum<…>

IntentOrigintype

type IntentOrigin = z.infer<typeof intentOriginSchema>;

editIntentSchemaschema

const editIntentSchema: z.ZodObject<…>

EditIntenttype

Shown in use on Proposing a change — Who writes the plan, Connecting a model — The slot, and what goes in it and What your app has to do — An ask names the page, and which version of it

type EditIntent = {
    readonly intentId: IntentId;
    readonly treeId: TreeId;
    readonly baseRevision: number;
    readonly origin: IntentOrigin;
    readonly actor?: string;
    readonly utterance: string;
    readonly scopeNodeId?: NodeId;
    readonly observedAt: string;
};

The model seam

runtime/interpreter

The AI seam.

InterpretationErrortype

Shown in use on Connecting a model — The slot, and what goes in it

type InterpretationError = {
    readonly code: "not-understood";
    readonly detail: string;
} | {
    readonly code: "no-change-needed";
    readonly detail: string;
}
/** The service could not answer now. The same request may succeed later. */
 | {
    readonly code: "interpreter-unavailable";
    readonly detail: string;
}
/**
 * This deployment cannot reach a model at all — no credential, no entitlement,
 * no billing. Kept apart from `interpreter-unavailable` because waiting never
 * fixes it: an operator does.
 */
 | {
    readonly code: "interpreter-misconfigured";
    readonly detail: string;
}
/**
 * The service rejected the request Loom assembled, and would reject the same
 * request again. This one is Loom's own fault, and it is the only failure here
 * that a deployment can neither wait out nor configure away.
 */
 | {
    readonly code: "interpreter-request-rejected";
    readonly detail: string;
}
/**
 * The interpreter itself declined — a safety classifier, a content policy, a
 * guardrail of its own. Kept apart from `interpreter-unavailable` because one
 * is a statement about the request and the other is a statement about the
 * service: a rising rate of the first is a content signal, a rising rate of
 * the second is an outage.
 */
 | {
    readonly code: "refused";
    readonly detail: string;
} | {
    readonly code: "malformed-proposal";
    readonly detail: string;
};

InterpretationFaulttype

Who would have to do something for the next attempt to go differently.

type InterpretationFault = 
/** The intent: nothing failed except the asking. */
"asker"
/** The answer that came back. */
 | "model"
/** The service, this time. */
 | "provider"
/** The deployment, until someone changes it. */
 | "deployment"
/** Loom, for what it sent. */
 | "runtime";

interpretationFaultfunction

const interpretationFault: (error: InterpretationError) => InterpretationFault

describeInterpretationErrorfunction

One sentence a reader who is not holding this union in their head can act on.

const describeInterpretationError: (error: InterpretationError) => string

ChangeInterpreterinterface

Shown in use on Proposing a change — Who writes the plan and Connecting a model — The slot, and what goes in it

interface ChangeInterpreter {
    readonly interpret: (intent: EditIntent, tree: LoomTree) => Promise<Result<ProposedChange, InterpretationError>>;
}

RepairRequesttype

What a repairer is told: the intent that was raised, the proposal the Gate refused, and the disposition that refused it. Deliberately the whole disposition rather than a summary — a repairer that cannot see which rule fired can only guess at what would be acceptable.

type RepairRequest = {
    readonly intent: EditIntent;
    readonly refused: ProposedChange;
    readonly disposition: Disposition;
};

ChangeRepairerinterface

Revising a refused proposal is a different job from interpreting an utterance, so it is a different interface. A runtime that is handed no repairer cannot repair, which makes "this deployment lets AI have a second go" a visible choice at the composition root rather than a property of whichever interpreter happened to be wired in.

Shown in use on Connecting a model — A second go

interface ChangeRepairer {
    readonly repair: (request: RepairRequest, tree: LoomTree) => Promise<Result<ProposedChange, InterpretationError>>;
}

Inverse

runtime/inverse

An undo that is already computed, offered as a change like any other.

ComputedInversetype

The operations that put something back, and what to say about them.

type ComputedInverse = {
    /** The undo, as operations. The delta's id and base belong to the proposal. */
    readonly operations: readonly TreeOperation[];
    /**
     * The revision the operations were computed against.
     *
     * An inverse is only an inverse of one arrangement. Offered against another it
     * would be proposing something whose reasoning has expired, so the interpreter
     * declines rather than proposing it — which is what makes this safe to wire
     * into a runtime directly, with no write path in front of it.
     */
    readonly headRevision: number;
    /** What produced the delta, for `Provenance.interpreter`. Not a model. */
    readonly interpreter: string;
    /** Why, in the words the person answering a hold reads. */
    readonly rationale: string;
    /**
     * Work this undo writes over, when the caller knows of some.
     *
     * Only a caller that read a log can know, so it is absent by default and
     * absence keeps meaning "nobody looked" rather than "a log was checked and was
     * clean".
     */
    readonly discards?: readonly DiscardedWork[];
    /**
     * The revision this puts back, recorded on the provenance the log keeps.
     *
     * Optional because not every inverse has one to name. A stateless surface
     * undoing a change it made in the same session has no revision — nothing was
     * ever appended — and stamping a number it does not have would be worse than
     * the absence. A caller that planned the undo off a log always does.
     */
    readonly undoes?: number;
};

inverseInterpreterfunction

An interpreter that has nothing to interpret.

const inverseInterpreter: (inverse: ComputedInverse, idFactory: IdFactory, clock: Clock) => ChangeInterpreter

Irreversibility

runtime/irreversibility

Why a change cannot be taken back, as data.

IrreversibilityReasontype

type IrreversibilityReason = {
    readonly code: "out-of-tree-effect";
    readonly primitiveTypes: readonly PrimitiveType[];
} | {
    readonly code: "retention-budget-exceeded";
    readonly retainedNodeCount: number;
    readonly budget: number;
};

A target inside a target

runtime/nesting

Where a tree puts one target inside another.

InteractivePredicatetype

Whether this element renders a target — its type and its own props, never its position.

type InteractivePredicate = (node: ElementNode) => boolean;

NOTHING_INTERACTIVEvalue

What a host that declares no interactive vocabulary gets, which is today's behaviour.

const NOTHING_INTERACTIVE: InteractivePredicate

interactivePredicateForfunction

const interactivePredicateFor: (types: InteractiveTypes) => InteractivePredicate

NestedTargettype

One target sitting inside another, named by the node the reader cannot reach.

type NestedTarget = {
    readonly nodeId: NodeId;
    readonly type: PrimitiveType;
    /** The nearest target above it, which is the one that swallows it. */
    readonly ancestorId: NodeId;
    readonly ancestorType: PrimitiveType;
};

nestedTargetsInfunction

Every target this tree puts inside another, in document order.

const nestedTargetsIn: (root: LoomNode, isInteractive: InteractivePredicate) => readonly NestedTarget[]

describeNestedTargetfunction

const describeNestedTarget: (nested: NestedTarget) => string

Pipeline

runtime/pipeline

The composition runtime: EditIntent → ProposedChange → Gate → Disposition → Apply, narrating each stage as it goes.

CompositionRuntimetype

Shown in use on What AI may change — Handing it to the runtime

type CompositionRuntime = {
    readonly interpreter: ChangeInterpreter;
    /**
     * Which policy judges a given change. A source rather than a policy, because
     * one runtime may serve trees that do not deserve the same latitude; a host
     * with one policy says so with `fixedPolicy`.
     */
    readonly policySource: PolicySource;
    readonly events: EventSink;
    readonly clock: Clock;
    readonly idFactory: IdFactory;
    /**
     * Optional, and absent by default. With no repairer, a refusal is terminal.
     * With one, a refused proposal gets exactly one more attempt — see
     * `attemptRepair`, where the "exactly one" is structural rather than a
     * counter that could drift.
     */
    readonly repairer?: ChangeRepairer;
    /**
     * What this deployment's primitives accept, checked before a delta is judged.
     * Optional, and absent by default: a runtime handed none cannot check props,
     * so "this deployment refuses AI-authored props its own schemas reject" is a
     * visible choice at the composition root rather than a property of whichever
     * interpreter happened to be wired in.
     *
     * Deliberately the same shape as the renderer's `PropsValidator`, so a host
     * with a registry hands the same object to both seams and the two cannot
     * disagree about what is drawable. `propsVocabularyFor(registry)` in the SDK
     * is how one is obtained without hand-keeping it.
     */
    readonly propsVocabulary?: PropsVocabulary;
    /**
     * What this deployment's primitives read an answer under, checked before a
     * delta is judged. Optional and absent by default, for every reason
     * `propsVocabulary` above gives — a runtime handed none cannot tell a question
     * nothing reads from one something does, and refusing on that ground is a
     * choice made at the composition root rather than inherited.
     *
     * Deliberately the renderer's own `BindingReader` rather than a second shape,
     * so what the walk reports as `data-unread` is what the write path refuses. An
     * SDK registry satisfies it already, which is why there is no
     * `bindingReaderFor(registry)` beside `propsVocabularyFor`: the registry **is**
     * one, and a helper that wrapped it would only be a place for the two to
     * disagree.
     */
    readonly bindingReader?: BindingReader;
};

CompositionOutcometype

type CompositionOutcome = {
    readonly kind: "applied";
    readonly tree: LoomTree;
    readonly assessment: ChangeAssessment;
    readonly disposition: Disposition;
    readonly inverse: TreeDelta;
} | {
    readonly kind: "awaiting-confirmation";
    readonly assessment: ChangeAssessment;
    readonly disposition: Disposition;
} | {
    readonly kind: "rejected";
    readonly assessment: ChangeAssessment;
    readonly disposition: Disposition;
    /**
     * Why no smaller change was offered instead — present only when a repairer
     * was asked for one and could not produce it.
     *
     * The three ways a refusal can end are told apart by this field and
     * `assessment.proposal.repairOf` together: `repairOf` set means a repair
     * was made and refused in its turn; this field set means the repairer
     * declined; neither means no repairer was wired, so nothing was asked.
     * Without it the second and third read identically, and a host that wanted
     * to say "we asked for something smaller and could not get one" had to
     * recover the fact from the event stream.
     */
    readonly repairFailure?: InterpretationError;
} | {
    readonly kind: "not-interpreted";
    readonly error: InterpretationError;
} | {
    readonly kind: "not-applicable";
    readonly proposal: ProposedChange;
    readonly error: TreeError;
};

CompositionOutcomeKindtype

type CompositionOutcomeKind = CompositionOutcome["kind"];

COMPOSITION_OUTCOME_KINDSvalue

The five ways an ask can end, in the order a host meets them.

const COMPOSITION_OUTCOME_KINDS: readonly CompositionOutcomeKind[]

composeChangefunction

const composeChange: (runtime: CompositionRuntime, tree: LoomTree, intent: EditIntent) => Promise<CompositionOutcome>

ConfirmationOutcometype

What confirming can produce. Narrower than CompositionOutcome because confirmation starts from a proposal that already exists: there is nothing left to interpret, and a change the Gate holds a second time is not offered a third — it applies or it does not.

type ConfirmationOutcome = Extract<CompositionOutcome, {
    readonly kind: "applied" | "rejected" | "not-applicable";
}>;

confirmChangefunction

Completes a change the Gate held back. The assessment is recomputed against the tree as it stands now rather than trusting the one captured at proposal time, so a confirmation cannot smuggle in a decision made about a different tree. A change the Gate now refuses outright stays refused, even with a human saying yes.

const confirmChange: (runtime: CompositionRuntime, tree: LoomTree, proposal: ProposedChange, intent: EditIntent) => ConfirmationOutcome

Policy

runtime/policy

Everything the Gate needs that is a choice rather than a fact.

gatePolicySchemaschema

Shown in use on What AI may change and What the Gate decides — The deployment decides what is consequential

const gatePolicySchema: z.ZodObject<…>

GatePolicytype

The policy's fields, stated once.

type GatePolicy = Readonly<z.infer<typeof gatePolicySchema>>;

defaultGatePolicyvalue

The structural knobs are opinionated; the vocabulary lists are empty because only the host knows which of its primitives are consequential. With no vocabulary declared, stakes are driven entirely by shape — how much is removed, how broadly, how close to the root.

const defaultGatePolicy: GatePolicy

ceilingForfunction

const ceilingFor: (policy: GatePolicy, origin: IntentOrigin) => StakeLevel

Policy fingerprint

runtime/policy-fingerprint

What a policy contains, as one comparable string.

policyFingerprintOffunction

const policyFingerprintOf: (policy: GatePolicy) => string

policyShapeOffunction

Which set of knobs a fingerprint describes. Total on purpose: a string that is not a fingerprint this version produced is treated as its own shape, so it compares as *incomparable* to everything. Failing toward "cannot tell" is the right direction — the alternative is a malformed record silently reading as agreement.

const policyShapeOf: (fingerprint: string) => string

RulesetContinuitytype

What a set of fingerprints seen under one policy name says about that name.

type RulesetContinuity = "unrecorded" | "single" | "changed" | "incomparable";

rulesetContinuityOffunction

const rulesetContinuityOf: (fingerprints: readonly string[]) => RulesetContinuity

Policy source

runtime/policy-source

Where the Gate's policy comes from.

PolicyContexttype

type PolicyContext = {
    readonly tree: LoomTree;
    readonly intent: EditIntent;
};

PolicySourceinterface

interface PolicySource {
    readonly resolve: (context: PolicyContext) => GatePolicy;
}

fixedPolicyfunction

One policy, for everything. The honest spelling of what a single-tenant host wants, and what the runtime used to assume everyone wanted.

Shown in use on What AI may change — Handing it to the runtime and What your app has to do — The three things a change needs

const fixedPolicy: (policy: GatePolicy) => PolicySource

Proposals

runtime/proposal

Provenance answers "where did this change come from" in enough detail to audit it later: who or what asked, which interpreter turned the ask into a delta, and how sure that interpreter was. Telemetry consumes exactly this record alongside the disposition and the eventual outcome.

authorKindSchemaschema

Who wrote the delta, as opposed to which component carried it.

const authorKindSchema: z.ZodEnum<…>

AuthorKindtype

type AuthorKind = z.infer<typeof authorKindSchema>;

provenanceSchemaschema

const provenanceSchema: z.ZodObject<…>

Provenancetype

type Provenance = {
    readonly origin: IntentOrigin;
    readonly actor?: string;
    readonly interpreter: string;
    readonly authoredBy: AuthorKind;
    readonly promptHash?: string;
    /** The revision this change puts back; absent unless it puts one back. */
    readonly undoes?: number;
    readonly confidence: number;
    readonly interpretedAt: string;
};

discardedWorkSchemaschema

Work a change would take out of the current tree that its own delta does not show: a revision already in the log, and which of the nodes it named this change writes over.

const discardedWorkSchema: z.ZodObject<…>

DiscardedWorktype

type DiscardedWork = {
    readonly revision: number;
    readonly nodeIds: readonly NodeId[];
};

proposedChangeSchemaschema

The unit the Gate reviews: a concrete delta, why the interpreter believes it satisfies the intent, and where it came from. A proposal is inert — nothing has touched the tree yet.

const proposedChangeSchema: z.ZodObject<…>

ProposedChangetype

Shown in use on Proposing a change — Who writes the plan and Connecting a model — The slot, and what goes in it

type ProposedChange = {
    readonly proposalId: ProposalId;
    readonly intentId: IntentId;
    readonly delta: TreeDelta;
    readonly rationale: string;
    readonly provenance: Provenance;
    readonly repairOf?: ProposalId;
    /**
     * Only ever set by an interpreter that computed the delta from a log. It can
     * make the Gate stricter and never more permissive, which is what makes it
     * safe to accept as a declaration rather than a computation the Gate repeats
     *.
     */
    readonly discards?: readonly DiscardedWork[];
};

Redirection

runtime/redirection

Where a change moves a form's destination.

RedirectedSubmissiontype

One form's destination, moved from one registered endpoint to another.

type RedirectedSubmission = {
    readonly nodeId: NodeId;
    readonly from: EndpointId;
    readonly to: EndpointId;
};

describeRedirectedSubmissionfunction

const describeRedirectedSubmission: (redirected: RedirectedSubmission) => string

redirectedSubmissionsBetweenfunction

Every destination this change moves: a node that posted somewhere before and posts somewhere else after.

const redirectedSubmissionsBetween: (before: LoomNode, after: LoomNode) => readonly RedirectedSubmission[]

Reversibility

runtime/reversibility

Reversibility is computed, not guessed: the inverse delta is produced up front, so "can this be undone" is answered by handing over the thing that undoes it.

Reversibilitytype

type Reversibility = {
    readonly reversible: boolean;
    /** The tree-level undo. Present even when `reversible` is false. */
    readonly inverse: TreeDelta;
    /** Nodes the inverse must carry — the content a removal destroyed. */
    readonly retainedNodeCount: number;
    readonly reasons: readonly IrreversibilityReason[];
};

assessReversibilityfunction

const assessReversibility: (tree: LoomTree, delta: TreeDelta, analysis: ChangeAnalysis, policy: GatePolicy, inverseDeltaId: DeltaId) => Result<Reversibility, TreeError>

Stake level

runtime/stake-level

The scale the gate weighs a change on, and the order it compares two of them in.

stakeLevelSchemaschema

How much damage a change does if it turns out to be wrong. Four levels is enough to separate "just do it" from "ask first" from "refuse", with one level of headroom in between.

const stakeLevelSchema: z.ZodEnum<…>

StakeLeveltype

type StakeLevel = z.infer<typeof stakeLevelSchema>;

STAKE_ORDERvalue

The scale in severity order, which is the order and not merely the set.

const STAKE_ORDER: readonly StakeLevel[]

compareStakesfunction

const compareStakes: (left: StakeLevel, right: StakeLevel) => number

isAtLeastfunction

const isAtLeast: (level: StakeLevel, floor: StakeLevel) => boolean

isAbovefunction

const isAbove: (level: StakeLevel, ceiling: StakeLevel) => boolean

highestStakefunction

The highest level present, or low when nothing raised a concern.

const highestStake: (levels: readonly StakeLevel[]) => StakeLevel

Stakes

runtime/stakes

Stakes assessment: facts plus policy in, a level plus its reasons out.

stakeFactorCodeSchemaschema

Which rule a factor is, as a closed vocabulary rather than a bare union.

const stakeFactorCodeSchema: z.ZodEnum<…>

StakeFactorCodetype

type StakeFactorCode = z.infer<typeof stakeFactorCodeSchema>;

STAKE_FACTOR_CODESvalue

Every rule the Gate can raise, walkable.

const STAKE_FACTOR_CODES: readonly StakeFactorCode[]

StakeFactortype

type StakeFactor = {
    readonly code: StakeFactorCode;
    readonly level: StakeLevel;
    readonly detail: string;
};

StakeAssessmenttype

type StakeAssessment = {
    readonly level: StakeLevel;
    readonly factors: readonly StakeFactor[];
};

StakeInputtype

Everything the damage estimate is computed from: what the delta does, and what its author declared it writes over.

type StakeInput = {
    readonly analysis: ChangeAnalysis;
    /** Empty when the proposal declared nothing, which is not the same as nothing. */
    readonly discards: readonly DiscardedWork[];
};

FixedStakeFactorCodetype

A rule no policy field can move, so its code alone gives its level.

type FixedStakeFactorCode = keyof typeof FIXED_LEVELS;

MeasuredStakeFactorCodetype

A rule a policy decides, so the same change under two policies is two answers. The complement of FixedStakeFactorCode by construction: a factor added to the vocabulary belongs to one set or the other and nothing has to remember to put it there.

type MeasuredStakeFactorCode = Exclude<StakeFactorCode, FixedStakeFactorCode>;

fixedStakeLevelfunction

The level this rule is always raised at, which is the whole of what its code means.

const fixedStakeLevel: (code: FixedStakeFactorCode) => StakeLevel

isFixedStakeFactorfunction

const isFixedStakeFactor: (code: StakeFactorCode) => code is FixedStakeFactorCode

FIXED_STAKE_FACTOR_CODESvalue

The two halves of the vocabulary, walkable, in the order the Gate raises them.

const FIXED_STAKE_FACTOR_CODES: readonly FixedStakeFactorCode[]

MEASURED_STAKE_FACTOR_CODESvalue

const MEASURED_STAKE_FACTOR_CODES: readonly MeasuredStakeFactorCode[]

StakeMeasurementtype

The facts the policy-dependent rules read, and nothing else.

type StakeMeasurement = {
    readonly insertedNodeCount: number;
    readonly removedNodeCount: number;
    readonly movedNodeCount: number;
    /** `affectedNodeIds.length`. Breadth reads the count and never the ids. */
    readonly affectedNodeCount: number;
    readonly shallowestAffectedDepth: number;
    readonly touchedPrimitiveTypes: readonly PrimitiveType[];
    readonly removedPrimitiveTypes: readonly PrimitiveType[];
    readonly relocatedPrimitiveTypes: readonly PrimitiveType[];
    readonly configuredPropKeys: readonly string[];
};

stakeMeasurementOffunction

The measurable half of an analysis, which is what the Gate's own path takes.

const stakeMeasurementOf: (analysis: ChangeAnalysis) => StakeMeasurement

measureStakesfunction

The policy-dependent half, against a measurement rather than a delta.

const measureStakes: (measurement: StakeMeasurement, policy: GatePolicy) => readonly StakeFactor[]

assessStakesfunction

const assessStakes: (input: StakeInput, policy: GatePolicy) => StakeAssessment

stakeFactorfunction

The factor with this code, for a caller that needs the reason and not the level.

const stakeFactor: (stakes: StakeAssessment, code: StakeFactorCode) => StakeFactor | undefined

Vocabulary

runtime/vocabulary

What a deployment's primitives are, as far as the write path is concerned.

PrimitiveVocabularytype

type PrimitiveVocabulary = (type: PrimitiveType) => boolean;

EVERY_TYPE_REGISTEREDvalue

What a host that has declared no vocabulary gets, which is today's behaviour.

const EVERY_TYPE_REGISTERED: PrimitiveVocabulary

primitiveVocabularyForfunction

The vocabulary a list of registered types describes.

const primitiveVocabularyFor: (types: readonly PrimitiveType[]) => PrimitiveVocabulary

UnknownPrimitivetype

A node a change would add whose type this deployment cannot draw.

type UnknownPrimitive = {
    readonly nodeId: NodeId;
    readonly type: PrimitiveType;
};

unknownPrimitivesInfunction

Every element in a subtree whose type the vocabulary does not hold, in document order.

const unknownPrimitivesIn: (node: LoomNode, isRegistered: PrimitiveVocabulary) => readonly UnknownPrimitive[]

describeUnknownPrimitivefunction

const describeUnknownPrimitive: (unknown: UnknownPrimitive) => string

PropsVocabularytype

What a deployment's primitives accept, as far as the write path is concerned.

type PropsVocabulary = (type: PrimitiveType, props: JsonObject) => PropsVerdict;

EVERY_TYPE_UNDECLAREDvalue

What a host that has wired no props vocabulary gets, which is today's behaviour.

const EVERY_TYPE_UNDECLARED: PropsVocabulary

InvalidPropstype

A node a change would leave carrying props its own primitive refuses.

type InvalidProps = {
    readonly nodeId: NodeId;
    readonly type: PrimitiveType;
    readonly issues: readonly PropsIssue[];
};

invalidPropsInfunction

Every element in a subtree whose props its declaring primitive refuses, in document order.

const invalidPropsIn: (node: LoomNode, checkProps: PropsVocabulary) => readonly InvalidProps[]

describeInvalidPropsfunction

A sentence naming the node, its type and what its schema said.

const describeInvalidProps: (invalid: InvalidProps) => string

NOTHING_DECLAREDvalue

What a host that has handed no reader gets, which is today's behaviour.

const NOTHING_DECLARED: BindingReader

UnreadBindingtype

A question a change would leave on the page that no primitive will look at.

type UnreadBinding = {
    readonly nodeId: NodeId;
    readonly type: PrimitiveType;
    /** The name the node asked under, which is the half the primitive does not hold. */
    readonly name: BindingName;
};

unreadBindingsInfunction

Every binding in a subtree whose name its own primitive says it does not read, in document order and name-sorted within a node.

const unreadBindingsIn: (node: LoomNode, reads: BindingReader) => readonly UnreadBinding[]

describeUnreadBindingfunction

const describeUnreadBinding: (unread: UnreadBinding) => string

Catalogue

submit/catalogue

Where a deployment will accept a submission.

CataloguedEndpointtype

type CataloguedEndpoint = {
    readonly id: EndpointId;
    /** One line, written by the endpoint's author, about what it receives. */
    readonly description: string;
};

SubmissionCataloguetype

type SubmissionCatalogue = readonly CataloguedEndpoint[];

submissionCataloguefunction

const submissionCatalogue: (registry: EndpointRegistry) => SubmissionCatalogue

Declaring where a form posts

submit/declaration

What a node says about where it posts, as it appears in the tree.

Submissiontype

type Submission = {
    readonly to: EndpointId;
};

SubmissionErrortype

type SubmissionError = {
    /** The path of the offending entry, for a diagnostic a person can act on. */
    readonly path: string;
    readonly message: string;
};

describeSubmissionErrorfunction

const describeSubmissionError: (error: SubmissionError) => string

parseSubmissionfunction

Parses the value of loom:submit. Total, like every other parse of something that came out of storage: a malformed declaration is reported and the node renders with no target, rather than throwing on a page nobody can then see.

const parseSubmission: (declared: unknown) => Result<Submission, SubmissionError>

Endpoint

submit/endpoint

The host's half of the submission seam: where a form's contents go.

endpointIdSchemaschema

The identifier of a registered endpoint — the contract between loom:submit in the tree and whatever receives the submission. contact.enquiry, newsletter.subscribe.

const endpointIdSchema: z.ZodBranded<…>

EndpointIdtype

type EndpointId = z.infer<typeof endpointIdSchema>;

SubmissionFieldtype

A hidden input the primitive must render inside the form. A CSRF token is the reason this exists, and it is the reason resolution is allowed to be slow: a token is minted per request, often against a store, and a seam that could not wait for one would push every host into minting it somewhere else and threading it through context by hand.

type SubmissionField = {
    readonly name: string;
    readonly value: string;
};

SubmissionTargettype

Where a form posts, as the primitive receives it. Every field here is host-authored; none of it is in the tree, and none of it survives into a delta, a revision or a diff.

type SubmissionTarget = {
    readonly action: string;
    readonly method: "get" | "post";
    /** Rendered as hidden inputs, in order. Empty for an endpoint needing none. */
    readonly fields: readonly SubmissionField[];
};

submissionTargetSchemaschema

const submissionTargetSchema: z.ZodType<…>

SubmissionFailuretype

What an endpoint says when it cannot give a target.

type SubmissionFailure = {
    /**
     * `unavailable` is "ask again later" — a token store that is down, a
     * dependency that timed out. `refused` is "not for you" — a form this
     * audience may not submit, an endpoint the deployment turned off. Separate
     * because a primitive shows different things for them, and because only one
     * of the two is worth trying again.
     */
    readonly code: "unavailable" | "refused";
    readonly detail: string;
};

EndpointRequesttype

type EndpointRequest = {
    /** The render request's opaque host context — audience, locale, tenant. */
    readonly context: JsonObject | undefined;
    /**
     * Aborted once the render has stopped waiting for this endpoint, which it
     * will. Pass it to whatever does the IO and the connection is let go;
     * ignore it and the page is still answered on time, but the socket is not.
     */
    readonly signal: AbortSignal | undefined;
};

SubmissionEndpointinterface

interface SubmissionEndpoint {
    readonly target: (request: EndpointRequest) => Promise<Result<SubmissionTarget, SubmissionFailure>>;
}

SubmissionUnavailabletype

Why a node has no target. Each is something a primitive may be told about and a diagnostic says out loud; none of them is an exception anyone catches.

type SubmissionUnavailable = {
    readonly reason: "no-such-endpoint" | "invalid-target" | "endpoint-threw" | SubmissionFailure["code"];
    readonly detail: string;
};

describeSubmissionUnavailablefunction

const describeSubmissionUnavailable: (unavailable: SubmissionUnavailable) => string

EndpointDefinitiontype

type EndpointDefinition = {
    readonly id: string;
    /**
     * One line, for the catalogue. A model choosing where a form posts has this
     * and the id to go on, and nothing else — which is the point.
     */
    readonly description: string;
    readonly endpoint: SubmissionEndpoint;
};

EndpointEntrytype

A definition with the host's own types erased, which is what a heterogeneous registry can hold. defineEndpoint is the only way to build one, so the validation of what the host answered happens in exactly one place.

type EndpointEntry = {
    readonly id: string;
    readonly description: string;
    /** Total: calls, catches, validates. Never rejects. */
    readonly resolve: (context: JsonObject | undefined, signal?: AbortSignal) => Promise<Result<SubmissionTarget, SubmissionUnavailable>>;
};

defineEndpointfunction

Declares an endpoint. The target it answers with is validated even though the host typed it, for the reason defineSource validates an answer: the type is a claim made when the code was written, and the schema is what makes the claim true on the day the route moved.

Shown in use on What a form posts to — Your half: registering an endpoint

const defineEndpoint: (definition: EndpointDefinition) => EndpointEntry

RegisteredEndpointtype

type RegisteredEndpoint = EndpointEntry & {
    readonly id: EndpointId;
};

EndpointRegistryErrortype

type EndpointRegistryError = {
    readonly code: "invalid-endpoint-id";
    readonly id: string;
} | {
    readonly code: "duplicate-endpoint-id";
    readonly id: string;
};

describeEndpointRegistryErrorfunction

Shown in use on What a form posts to — Your half: registering an endpoint

const describeEndpointRegistryError: (error: EndpointRegistryError) => string

EndpointRegistryinterface

interface EndpointRegistry {
    readonly endpoint: (id: EndpointId) => RegisteredEndpoint | undefined;
    /** In registration order, so a catalogue reads predictably. */
    readonly endpoints: readonly RegisteredEndpoint[];
}

createEndpointRegistryfunction

Building the registry is pure and calls no endpoint, so registering cannot mint a token as a side effect of an import.

Shown in use on What a form posts to — Your half: registering an endpoint

const createEndpointRegistry: (entries: readonly EndpointEntry[]) => Result<EndpointRegistry, EndpointRegistryError>

Planning a form's destination

submit/plan

Which endpoints a tree points at, worked out before any of them is asked.

PlannedSubmissiontype

type PlannedSubmission = {
    readonly nodeId: NodeId;
    readonly to: EndpointId;
};

SubmissionProblemtype

A node whose loom:submit could not be read at all.

type SubmissionProblem = {
    readonly nodeId: NodeId;
    readonly error: SubmissionError;
};

SubmissionPlantype

type SubmissionPlan = {
    /** Deduplicated endpoint ids, in first-encountered order. */
    readonly endpoints: readonly EndpointId[];
    readonly submissions: readonly PlannedSubmission[];
    readonly problems: readonly SubmissionProblem[];
};

submissionPlanIsEmptyfunction

const submissionPlanIsEmpty: (plan: SubmissionPlan) => boolean

planTreeSubmissionsfunction

Two forms naming one endpoint are resolved once and share a target. That is the rule that identical questions are asked once, applied to a question that has no params to differ by, and it is also the behaviour a reader would expect: two newsletter forms on one page post to the same place, and a per-form nonce would break the second one every time somebody used the first.

const planTreeSubmissions: (tree: LoomTree) => SubmissionPlan

planSubmissionsInfunction

The same plan, for a root that is not a stored tree.

const planSubmissionsIn: (root: LoomNode) => SubmissionPlan

What a resolved destination says

submit/resolution

The targets, indexed the way a render walk needs them.

SubmissionOutcometype

A node that declared a submission either has a target or has a named reason it does not. There is no third state here, and in particular no way to express "no target, and that is fine": a form primitive shows something different for "we cannot take this right now" than for a form that works, and a shape that cannot tell them apart guarantees it eventually shows a submit button that quietly goes nowhere.

type SubmissionOutcome = {
    readonly status: "ready";
    readonly target: SubmissionTarget;
} | {
    readonly status: "unavailable";
    readonly unavailable: SubmissionUnavailable;
};

NodeSubmissionProblemtype

What a node's submission could not do, in the seam's own vocabulary.

type NodeSubmissionProblem = {
    readonly kind: "misdeclared";
    readonly error: SubmissionError;
} | {
    readonly kind: "unavailable";
    readonly to: EndpointId;
    readonly unavailable: SubmissionUnavailable;
};

SubmissionResolutioninterface

interface SubmissionResolution {
    /**
     * `undefined` for the overwhelming majority of nodes, which declare none — and
     * for that reason it is not a way to say "I have no target for you". A node that
     * declares `loom:submit` and gets `undefined` with no problem beside it earns a
     * `submit-unresolved` diagnostic from the walk, for the reason the data seam's
     * twin gives at length.
     */
    readonly lookup: (nodeId: NodeId) => SubmissionOutcome | undefined;
    readonly problemsFor: (nodeId: NodeId) => readonly NodeSubmissionProblem[];
}

EMPTY_SUBMISSION_RESOLUTIONvalue

The resolution of a plan in which nothing posts anywhere, which is the only tree it is correct for. EMPTY_DATA_RESOLUTION has the same shape, the same purpose and the same trap.

const EMPTY_SUBMISSION_RESOLUTION: SubmissionResolution

buildSubmissionResolutionfunction

Folds a plan and its answers into the two lookups the walk uses. Separated from resolution because it is pure: given the same answers it builds the same result, which is what makes every failure path testable without a fake endpoint for each one.

const buildSubmissionResolution: (plan: SubmissionPlan, answers: ReadonlyMap<EndpointId, Result<SubmissionTarget, SubmissionUnavailable>>) => SubmissionResolution

Resolving a destination

submit/resolve

The second step in serving a page that may do IO, and the only one that talks to a host about where a form posts.

ResolveSubmissionsOptionstype

type ResolveSubmissionsOptions = {
    readonly registry: EndpointRegistry;
    /** Passed through to every endpoint — the render request's host context. */
    readonly context?: JsonObject;
    /**
     * How long any one endpoint is given, each independently of the others.
     * Defaults to `DEFAULT_ENDPOINT_CEILING_MS`; there is no way to wait forever
     *.
     */
    readonly ceilingMs?: number;
};

DEFAULT_ENDPOINT_CEILING_MSvalue

Ten seconds, the data seam's number and for its reason: this is a page being served, and a reader is waiting for it.

const DEFAULT_ENDPOINT_CEILING_MS = 10000

resolveSubmissionPlanfunction

const resolveSubmissionPlan: (plan: SubmissionPlan, options: ResolveSubmissionsOptions) => Promise<SubmissionResolution>

resolveTreeSubmissionsfunction

Plan and resolve in one step, for the ordinary caller that does both.

Shown in use on What a form posts to — When it is resolved, and why not later

const resolveTreeSubmissions: (tree: LoomTree, options: ResolveSubmissionsOptions) => Promise<SubmissionResolution>

Applying a theme

theme/apply

A resolved theme, flattened into CSS custom properties.

ThemeVariablestype

type ThemeVariables = Readonly<Record<string, string>>;

themeVariablesfunction

const themeVariables: (theme: ResolvedTheme) => ThemeVariables

Contrast

theme/contrast

The contrast bar, as a function a host can run against its own palettes.

TEXT_CONTRAST_MINIMUMvalue

WCAG AA for body text — the bar this module holds every text slot to.

const TEXT_CONTRAST_MINIMUM = 4.5

PALETTE_TEXT_GROUNDSvalue

The grounds every ink in the text ramp has to be readable on.

const PALETTE_TEXT_GROUNDS: readonly PaletteSlot[]

PairingBasistype

How a pairing comes about, which decides what a failure means.

type PairingBasis = "painted" | "composed";

TextPairingtype

A foreground slot read on a background slot, and where they meet.

type TextPairing = {
    readonly foreground: PaletteSlot;
    readonly background: PaletteSlot;
    readonly basis: PairingBasis;
    /** Where it happens, so a failure names a page rather than two slot ids. */
    readonly where: string;
};

PALETTE_TEXT_PAIRINGSvalue

const PALETTE_TEXT_PAIRINGS: readonly TextPairing[]

channelsOffunction

The colour as three 0–255 channels, or nothing when it is a form this cannot measure.

const channelsOf: (colour: string) => readonly [number, number, number] | undefined

relativeLuminancefunction

Relative luminance, or undefined for a form channelsOf declines to guess at.

const relativeLuminance: (colour: string) => number | undefined

contrastRatiofunction

The WCAG contrast ratio between two colours, or undefined when either is a form channelsOf declines to guess at.

const contrastRatio: (a: string, b: string) => number | undefined

MeasuredPairingtype

A pairing that was measured, whether or not it cleared the bar.

type MeasuredPairing = {
    readonly pairing: TextPairing;
    readonly ratio: number;
    readonly meets: boolean;
};

UnmeasuredPairingtype

A pairing whose colours could not be measured, and which colour stopped it.

type UnmeasuredPairing = {
    readonly pairing: TextPairing;
    readonly foreground: string;
    readonly background: string;
};

PaletteAudittype

What the bar found in one palette: what was measured, what failed, what could not be.

type PaletteAudit = {
    readonly palette: ThemeId;
    readonly measured: readonly MeasuredPairing[];
    /**
     * Measured, `painted`, and under the bar. The list a host asserts empty.
     *
     * Painted only, because a painted failure is a page nobody can read and no
     * tree can avoid it — both ends are one primitive's own. That is a defect in
     * the palette with one fix, and asserting it empty is a promise a palette can
     * keep.
     */
    readonly failures: readonly MeasuredPairing[];
    /**
     * Measured, `composed`, and under the bar — reported rather than asserted.
     *
     * The split is not a softer bar for the same fault, and it is worth being
     * plain about why, because a second list is exactly where an inconvenient
     * failure would go to be forgotten.
     *
     * A composed pairing needs a tree that puts the two together. It is reachable
     * — `loom.perk` inside a `loom.section tone="accent"` is an ordinary page —
     * but whether a given deployment reaches it depends on trees nobody has
     * written yet, and the fix is not always the palette's: an ink that fails on
     * one ground and clears the other three may be a panel that wants moving.
     * Loom measures and reports, and imposing is the host's call — the bargain
     * the contrast bar already makes, applied here one level in.
     *
     * What keeps it honest is that nothing may be *demoted* into it. A pairing
     * any primitive paints is `painted` in the declared list whatever else also
     * composes it, and `library.test.ts` fails if the declared basis is softer
     * than the derivation's.
     */
    readonly composedFailures: readonly MeasuredPairing[];
    /**
     * Neither a pass nor a failure. Separate from `failures` for the reason
     * `notProbeable` is separate in the registry audit: a host that wants the
     * guarantee asserts both empty, and a host whose palette is written in `hsl()`
     * can tell "unreadable" from "unmeasurable".
     */
    readonly unmeasured: readonly UnmeasuredPairing[];
};

auditPalettefunction

Measures every pairing the primitives render, in one palette. Refuses nothing.

Shown in use on Making it look like yours — Can it actually be read?

const auditPalette: (palette: Palette, pairings?: readonly TextPairing[]) => PaletteAudit

describePaletteAuditfunction

One line per problem, for a CLI or a failing test's message. Empty when clean.

const describePaletteAudit: (audit: PaletteAudit) => string

Derive

theme/derive

Builds a palette that clears the bar, from three hues and a mode.

MARK_SEPARATION_TARGETvalue

const MARK_SEPARATION_TARGET: number

PaletteModetype

type PaletteMode = "light" | "dark";

HueSpectype

type HueSpec = {
    /** 0–360. */
    readonly hue: number;
    /** 0–100. Zero gives a neutral grey, which is how the monochrome palettes are built. */
    readonly saturation: number;
};

PaletteSpectype

type PaletteSpec = {
    readonly id: string;
    readonly name: string;
    readonly description: string;
    readonly mode: PaletteMode;
    /** The page itself. Its hue tints every neutral, which is what stops a palette looking like grey with colour on top. */
    readonly canvas: HueSpec;
    /** The colour that carries meaning as text. Darkened, or lightened, until it can. */
    readonly accent: HueSpec;
    /** The second colour. Never carries text, so it keeps its chroma — this is what `loom.hero`'s aurora paints. */
    readonly secondary: HueSpec;
    /**
     * Lightness of the canvas, the surface behind a card, and the muted well, in
     * that order. Defaults suit most palettes; a deeper dark mode or a warmer
     * paper is where you would change them.
     */
    readonly levels?: readonly [number, number, number];
    /**
     * Forces `accent` to the end of the range rather than to the lightest value
     * that clears the bar. A palette with no chroma has nothing to solve for, and
     * solving anyway leaves a mid-grey primary button that reads as a disabled
     * one.
     */
    readonly extremeAccent?: boolean;
};

hslHexfunction

HSL to a six-digit hex, which is the only form contrastRatio measures.

const hslHex: (hue: number, saturation: number, lightness: number) => string

solveLightnessfunction

The lightest ink (or darkest, on a dark palette) at this hue that still clears target against on.

const solveLightness: (hue: number, saturation: number, on: string, target: number, darkening: boolean) => string

solveMarkLightnessfunction

The lightness *nearest* from at this hue whose colour clears target ΔE against every one of grounds.

const solveMarkLightness: (hue: number, saturation: number, from: number, grounds: readonly string[], target: number) => string

derivePalettefunction

Derives a palette. Total: it always returns one, and auditPalette on the result is the check that it worked — see derivePaletteChecked for the pair.

Shown in use on Making it look like yours — Where your brand color actually goes

const derivePalette: (spec: PaletteSpec) => Palette

derivePaletteCheckedfunction

The palette and its audit together, which is how a host should call this: the derivation is a rule and the audit is the check on it, and a rule that has never been checked is a rule that will eventually be wrong at one hue.

const derivePaletteChecked: (spec: PaletteSpec) => {
    readonly palette: Palette;
    readonly clean: boolean;
}

canCarryTextfunction

Whether a brand colour can carry text on a given ground.

Shown in use on Making it look like yours — Where your brand color actually goes

const canCarryText: (colour: string, on: string) => boolean

Faces

theme/faces

Where a face actually is, for anything that cannot look one up.

FaceRoletype

The three roles a pack names a family for.

type FaceRole = "heading" | "body" | "mono";

FACE_ROLESvalue

const FACE_ROLES: readonly FaceRole[]

GENERIC_FAMILIESvalue

The CSS generic families, which name a face without addressing one.

const GENERIC_FAMILIES: readonly string[]

isGenericFamilyfunction

const isGenericFamily: (family: string) => boolean

leadingFamilyfunction

The first family a stack asks for, unquoted.

const leadingFamily: (stack: string) => string

familyStackForRolefunction

const familyStackForRole: (pack: FontPack, role: FaceRole) => string | undefined

declaredFacesfunction

Every face the pack says where to find, in declaration order.

const declaredFaces: (pack: FontPack) => readonly FontFace[]

facesForRolefunction

The declared faces whose family is the one this role's stack leads with.

const facesForRole: (pack: FontPack, role: FaceRole) => readonly FontFace[]

familiesWithoutSourcefunction

The families this pack asks for first and does not say where to find.

const familiesWithoutSource: (pack: FontPack) => readonly string[]

fontFaceRulesfunction

The pack's declared faces as @font-face rules, for a host that wants them.

const fontFaceRules: (pack: FontPack) => string

Font packs

theme/font-packs

The seventeen font packs the starter library ships beyond the first three.

grotesqueFontPackvalue

const grotesqueFontPack: FontPack

transitionalFontPackvalue

const transitionalFontPack: FontPack

didoneFontPackvalue

const didoneFontPack: FontPack

humanistFontPackvalue

const humanistFontPack: FontPack

slabFontPackvalue

const slabFontPack: FontPack

monoFontPackvalue

const monoFontPack: FontPack

monoDisplayFontPackvalue

const monoDisplayFontPack: FontPack

geometricFontPackvalue

const geometricFontPack: FontPack

classicalFontPackvalue

const classicalFontPack: FontPack

condensedFontPackvalue

const condensedFontPack: FontPack

roundedFontPackvalue

const roundedFontPack: FontPack

nativeFontPackvalue

const nativeFontPack: FontPack

typewriterFontPackvalue

const typewriterFontPack: FontPack

interUiFontPackvalue

const interUiFontPack: FontPack

displaySerifFontPackvalue

const displaySerifFontPack: FontPack

spaceFontPackvalue

const spaceFontPack: FontPack

workhorseFontPackvalue

const workhorseFontPack: FontPack

ADDITIONAL_FONT_PACKSvalue

In catalogue order: the stack packs first, then the four that need a host to serve a face. A model reading down the list meets something that always works before something that depends on a deployment it cannot see.

const ADDITIONAL_FONT_PACKS: readonly FontPack[]

Lab

theme/lab

A colour in CIELAB, which is where every perceptual question about a palette gets answered.

linearisefunction

sRGB channel, linearised.

const linearise: (value: number) => number

Labtype

Lightness, green–red, blue–yellow.

type Lab = readonly [number, number, number];

labOffunction

L*a*b* under D65, the white point sRGB is defined against, or undefined when the colour is a form channelsOf declines to guess at.

const labOf: (colour: string) => Lab | undefined

Library

theme/library

The starter vocabulary, ported from the Hermes registry.

editorialPalettevalue

const editorialPalette: Palette

boldPalettevalue

const boldPalette: Palette

editorialSerifFontPackvalue

Families are declared with their own fallback stacks and no loader. Loom does not fetch fonts: a host that wants a webfont links it, and a primitive renders correctly in the fallback until it arrives. Hermes emitted a Google Fonts URL here, which put a network dependency inside a pure function.

const editorialSerifFontPack: FontPack

boldSansFontPackvalue

const boldSansFontPack: FontPack

comfortableStylePresetvalue

const comfortableStylePreset: StylePreset

airyModernStylePresetvalue

const airyModernStylePreset: StylePreset

minimalPalettevalue

The third palette, and the first one specified by the maintainer rather than ported: white paper, black ink, and one green.

const minimalPalette: Palette

minimalSansFontPackvalue

One grotesque at two weights, which is the typographic half of minimalism.

const minimalSansFontPack: FontPack

preciseStylePresetvalue

Small radii, wide gutters, quick motion.

const preciseStylePreset: StylePreset

STARTER_PALETTESvalue

The three palettes this file authors by hand, then the eighteen derived ones (palettes.ts). The three come first because they are the ones the four surfaces wear and the ones the re-theme tests are written against; the rest are range.

Shown in use on Making it look like yours — Registering your own

const STARTER_PALETTES: readonly Palette[]

STARTER_FONT_PACKSvalue

const STARTER_FONT_PACKS: readonly FontPack[]

STARTER_STYLE_PRESETSvalue

const STARTER_STYLE_PRESETS: readonly StylePreset[]

Measure

theme/measure

What a palette *is*, as numbers a stylesheet can use.

MAX_SRGB_CHROMAvalue

The greatest chroma sRGB can reach: pure blue, #0000ff, at C* 133.82.

const MAX_SRGB_CHROMA = 133.82

PALETTE_CHROMA_SLOTSvalue

The slots a palette puts its colour in, and the only ones whose chroma tells anybody anything.

const PALETTE_CHROMA_SLOTS: readonly PaletteSlot[]

SCRIM_DARK_CEILINGvalue

Relative luminance at or under which a wash genuinely darkens what is beneath it.

const SCRIM_DARK_CEILING = 0.15

Scrimtype

A ground that darkens what is under it, and the ink that is guaranteed to read on it.

type Scrim = {
    readonly ground: PaletteSlot;
    readonly foreground: PaletteSlot;
    /** Of the ground. How much this wash can actually darken. */
    readonly luminance: number;
    /** Of the pair, which is the palette's own body-copy ratio. */
    readonly ratio: number;
    /** Whether the ground is under `SCRIM_DARK_CEILING`. */
    readonly darkens: boolean;
};

slotChromafunction

How much colour is in one slot, as a fraction of the most sRGB can hold, or undefined when the colour is a form channelsOf declines to guess at.

Shown in use on Making it look like yours — The third answer is undefined, and it is the one to plan for

const slotChroma: (colour: string) => number | undefined

paletteScrimfunction

The palette's scrim pair, or undefined when either end is a colour this cannot measure — undefined rather than a guess, so a host whose palette is written in hsl() is told the wash is unavailable instead of being handed a pair that might be the wrong way round.

Shown in use on Making it look like yours — The third answer is undefined, and it is the one to plan for

const paletteScrim: (palette: Palette) => Scrim | undefined

PaletteSchemetype

Which way round a palette is.

type PaletteScheme = "light" | "dark";

paletteSchemefunction

Which way round the palette is, or undefined when either end of its body-copy pair is a colour channelsOf declines to read.

Shown in use on Making it look like yours — Light or dark, as a word

const paletteScheme: (palette: Palette) => PaletteScheme | undefined

PaletteMeasurestype

Everything measurable about one palette that its slots do not say.

type PaletteMeasures = {
    readonly palette: ThemeId;
    /**
     * Chroma per slot, normalised, for the slots that carry colour. A slot whose
     * colour could not be measured is **absent** rather than zero: zero is a real
     * answer meaning grey, and `graphite`'s accent really is 0.000.
     */
    readonly chroma: Readonly<Partial<Record<PaletteSlot, number>>>;
    readonly scrim: Scrim | undefined;
    /**
     * Which way round the palette is, and `undefined` on the same terms `scrim`
     * is: a pair this cannot read is reported as unavailable rather than guessed.
     */
    readonly scheme: PaletteScheme | undefined;
};

paletteMeasuresfunction

Measures one palette. Refuses nothing, and reports what it could not measure by leaving it out.

const paletteMeasures: (palette: Palette) => PaletteMeasures

CHROMA_PLACESvalue

The decimal places a chroma is emitted to.

const CHROMA_PLACES = 3

chromaValuefunction

A chroma as a stylesheet reads it: unitless, so calc() can multiply by it.

const chromaValue: (chroma: number) => string

Palettes

theme/palettes

The eighteen palettes the starter library ships beyond the first three.

paperPalettevalue

const paperPalette: Palette

slatePalettevalue

const slatePalette: Palette

sagePalettevalue

const sagePalette: Palette

blushPalettevalue

const blushPalette: Palette

harbourPalettevalue

const harbourPalette: Palette

citrusPalettevalue

const citrusPalette: Palette

lilacPalettevalue

const lilacPalette: Palette

graphitePalettevalue

const graphitePalette: Palette

clayPalettevalue

const clayPalette: Palette

linenPalettevalue

const linenPalette: Palette

midnightPalettevalue

const midnightPalette: Palette

carbonPalettevalue

const carbonPalette: Palette

plumPalettevalue

const plumPalette: Palette

forestPalettevalue

const forestPalette: Palette

emberPalettevalue

const emberPalette: Palette

duskPalettevalue

const duskPalette: Palette

obsidianPalettevalue

const obsidianPalette: Palette

tidePalettevalue

const tidePalette: Palette

DERIVED_PALETTESvalue

In the order a catalogue reads best: the light palettes, then the dark ones, each run from the most neutral to the most committed. A model reading down this list meets paper and slate before plum, which is the order a person would offer them in.

const DERIVED_PALETTES: readonly Palette[]

Registry

theme/registry

Resolving three ids into three documents, and refusing when it cannot.

ThemeErrortype

type ThemeError = {
    readonly code: "unknown-palette";
    readonly id: string;
    readonly available: readonly string[];
} | {
    readonly code: "unknown-font-pack";
    readonly id: string;
    readonly available: readonly string[];
} | {
    readonly code: "unknown-style-preset";
    readonly id: string;
    readonly available: readonly string[];
} | {
    readonly code: "malformed-selection";
    readonly detail: string;
};

describeThemeErrorfunction

const describeThemeError: (error: ThemeError) => string

ThemeCatalogueEntrytype

type ThemeCatalogueEntry = {
    readonly id: string;
    readonly name: string;
    readonly description: string;
};

ThemeCataloguetype

What a deployment may be themed with, as data — the theme half of what catalogueOf projects for primitives, and shown to a model for the same reason: an id it was never told about is an id it can only guess.

type ThemeCatalogue = {
    readonly palettes: readonly ThemeCatalogueEntry[];
    readonly fontPacks: readonly ThemeCatalogueEntry[];
    readonly stylePresets: readonly ThemeCatalogueEntry[];
};

ThemeRegistryinterface

Shown in use on Rendering a tree — Themes are three ids on the root

interface ThemeRegistry {
    /**
     * `unknown` in, because a selection arrives from the tree — storage is a
     * boundary, and the parse below is the only thing that says a selection is
     * one. A caller holding a `ThemeSelection` already satisfies it.
     */
    readonly resolve: (selection: unknown) => Result<ResolvedTheme, ThemeError>;
    /** What a deployment can be themed with, as data a model can be shown. */
    readonly catalogue: () => ThemeCatalogue;
}

ThemeRegistryInputtype

type ThemeRegistryInput = {
    readonly palettes?: readonly Palette[];
    readonly fontPacks?: readonly FontPack[];
    readonly stylePresets?: readonly StylePreset[];
};

createThemeRegistryfunction

Shown in use on Rendering a tree and Making it look like yours — Registering your own

const createThemeRegistry: (input?: ThemeRegistryInput) => ThemeRegistry

Separation

theme/separation

Whether two slots a reader is meant to tell apart are far enough apart to be told apart. The other half of what a palette owes a page.

JUST_NOTICEABLE_DIFFERENCEvalue

ΔE below which two colours are the same colour to a reader.

const JUST_NOTICEABLE_DIFFERENCE = 2.3

colourDifferencefunction

The CIE76 colour difference between two colours, or undefined when either is a form channelsOf declines to guess at.

const colourDifference: (a: string, b: string) => number | undefined

PeerBasistype

How the reader is told the two apart, which decides what a collapse means.

type PeerBasis = "colour-only" | "also-marked";

PeerPairingtype

Two slots a reader is meant to tell apart, and what tells them apart.

type PeerPairing = (PeerBase & {
    readonly basis: "colour-only";
}) | (PeerBase & {
    readonly basis: "also-marked";
    readonly mark: PaletteSlot;
});

PALETTE_PEER_PAIRINGSvalue

The pairs the library actually asks a reader to distinguish.

const PALETTE_PEER_PAIRINGS: readonly PeerPairing[]

MarkGroundingtype

A slot the library draws as a line, and the grounds it is drawn against.

type MarkGrounding = {
    readonly mark: PaletteSlot;
    readonly grounds: readonly PaletteSlot[];
    /** Where the library draws it, so a failure names a primitive. */
    readonly where: string;
};

PALETTE_MARK_GROUNDINGSvalue

Every slot the library draws as a line, with the grounds it lands on.

const PALETTE_MARK_GROUNDINGS: readonly MarkGrounding[]

MeasuredMarktype

One slot against one of the grounds it is drawn on.

type MeasuredMark = {
    readonly grounding: MarkGrounding;
    readonly ground: PaletteSlot;
    /** `undefined` when either colour is a form `channelsOf` declines to guess at. */
    readonly difference: number | undefined;
    /** False for a difference this could not measure: an undefended line is not a defended one. */
    readonly visible: boolean;
};

MarkAudittype

What one palette does with the lines the library draws in it.

type MarkAudit = {
    readonly palette: ThemeId;
    readonly measured: readonly MeasuredMark[];
    /**
     * The lines a reader cannot find. A border the library declares and the
     * palette does not draw — the list a host asserts empty.
     */
    readonly invisible: readonly MeasuredMark[];
};

auditMarkGroundingsfunction

Measures every declared mark against every ground it is drawn on. Refuses nothing, decides nothing — the same bargain auditSeparation makes.

const auditMarkGroundings: (palette: Palette, groundings?: readonly MarkGrounding[]) => MarkAudit

describeMarkAuditfunction

One line per invisible mark, for a CLI or a failing test's message. Empty when clean.

const describeMarkAudit: (audit: MarkAudit) => string

MeasuredPeertype

A peer that was measured, whether or not it came apart.

type MeasuredPeer = {
    readonly pairing: PeerPairing;
    readonly difference: number;
    /**
     * How far the mark is from whichever of the two it is nearer to — the mark's
     * worst case, since it has to be visible against both.
     *
     * `undefined` for a `colour-only` peer, which has no mark, and for a mark
     * written in a form this cannot measure. The second case judges the pair on
     * its colours alone, which is the safe direction: a defence that cannot be
     * measured is not counted as one.
     */
    readonly markDifference: number | undefined;
    readonly separated: boolean;
};

UnmeasuredPeertype

A peer whose colours could not be measured, and which colour stopped it.

type UnmeasuredPeer = {
    readonly pairing: PeerPairing;
    readonly first: string;
    readonly second: string;
};

PaletteSeparationtype

What one palette does with the pairs its reader has to tell apart.

type PaletteSeparation = {
    readonly palette: ThemeId;
    readonly measured: readonly MeasuredPeer[];
    /**
     * Colour-only peers under the threshold: two things that look like one thing,
     * with nothing else to go on. The list a host asserts empty.
     */
    readonly collapsed: readonly MeasuredPeer[];
    /**
     * Marked peers whose colours have collapsed **and** whose mark has too — a
     * card that is neither filled nor outlined. Separate from `collapsed` because
     * it is a different defect with a different fix, and because a marked pair
     * whose mark is doing its job is not a defect at all.
     */
    readonly unmarked: readonly MeasuredPeer[];
    /** Neither separated nor collapsed: a colour this cannot measure. */
    readonly unmeasured: readonly UnmeasuredPeer[];
};

auditSeparationfunction

Measures every declared peer in one palette. Refuses nothing.

const auditSeparation: (palette: Palette, peers?: readonly PeerPairing[]) => PaletteSeparation

describeSeparationAuditfunction

One line per problem, for a CLI or a failing test's message. Empty when clean.

const describeSeparationAudit: (audit: PaletteSeparation) => string

Style presets

theme/style-presets

The seven style presets the starter library ships beyond the first three, and the reason there are seven rather than seventeen.

editorialPrintStylePresetvalue

const editorialPrintStylePreset: StylePreset

brutalistStylePresetvalue

const brutalistStylePreset: StylePreset

softStylePresetvalue

const softStylePreset: StylePreset

compactStylePresetvalue

const compactStylePreset: StylePreset

technicalStylePresetvalue

const technicalStylePreset: StylePreset

showcaseStylePresetvalue

const showcaseStylePreset: StylePreset

cardStylePresetvalue

const cardStylePreset: StylePreset

ADDITIONAL_STYLE_PRESETSvalue

In catalogue order, from the most neutral to the most committed: a model reading down the list meets card and technical before brutalist.

const ADDITIONAL_STYLE_PRESETS: readonly StylePreset[]

Themes

theme/theme

The theme vocabulary, ported from the Hermes registry (§4b).

paletteSlotSchemaschema

Every palette declares every slot. Normalised on purpose: a tree themed with one palette can be re-themed with any other, because there is no slot a primitive might read that some palette leaves undefined.

const paletteSlotSchema: z.ZodEnum<…>

PaletteSlottype

type PaletteSlot = z.infer<typeof paletteSlotSchema>;

PALETTE_SLOTSvalue

const PALETTE_SLOTS: ["bg-canvas", "bg-surface", "bg-surface-muted", "bg-overlay", "fg-default", "fg-muted", "fg-subtle", "fg-on-accent", "accent", "accent-strong", "accent-subtle", "brand-secondary", "brand-secondary-strong", "border-default", "border-strong", "border-subtle", "border-accent"]

RAMP_STEPSvalue

The type ramp and the spacing scale have a fixed number of steps, for exactly the reason every palette declares every slot: a primitive reading --loom-spacing-7 must get a length from any registered preset, or a re-theme silently unstyles it. A ramp whose length varied would make "renders under both palettes" a property of which two you happened to pick.

const RAMP_STEPS = 8

themeIdSchemaschema

const themeIdSchema: z.ZodBranded<…>

ThemeIdtype

type ThemeId = z.infer<typeof themeIdSchema>;

paletteSchemaschema

const paletteSchema: z.ZodObject<…>

Palettetype

type Palette = z.infer<typeof paletteSchema>;

fontFaceSchemaschema

const fontFaceSchema: z.ZodObject<…>

FontFacetype

type FontFace = z.infer<typeof fontFaceSchema>;

fontPackSchemaschema

A pack declares a face for each role the library actually reads, and no others. There were three roles here and a primitive read two of them: an accentFamily was emitted as --loom-accent-family and referenced by nothing, while loom.code and loom.kbd needed a monospace face the vocabulary had no word for. A variable a host can set and no primitive consults is worse than an absent one — it looks like a seam and behaves like a comment.

const fontPackSchema: z.ZodObject<…>

FontPacktype

type FontPack = z.infer<typeof fontPackSchema>;

stylePresetSchemaschema

const stylePresetSchema: z.ZodObject<…>

StylePresettype

type StylePreset = z.infer<typeof stylePresetSchema>;

themeSelectionSchemaschema

What a tree carries: three ids, not three documents.

const themeSelectionSchema: z.ZodObject<…>

ThemeSelectiontype

type ThemeSelection = z.infer<typeof themeSelectionSchema>;

ResolvedThemetype

Shown in use on Making it look like yours — Putting it on a page

type ResolvedTheme = {
    readonly palette: Palette;
    readonly fontPack: FontPack;
    readonly stylePreset: StylePreset;
};

Apply

tree/apply

Delta application. Pure: a tree and a delta go in, a new tree or a TreeError comes out. Nothing here logs, persists, or emits — the composition runtime wraps this function with those concerns so that the structural rules stay testable in isolation.

applyOperationfunction

One operation against one tree, with no delta around it.

const applyOperation: (root: LoomNode, operation: TreeOperation) => Result<LoomNode, TreeError>

applyDeltafunction

const applyDelta: (tree: LoomTree, delta: TreeDelta) => Result<LoomTree, TreeError>

Builders

tree/builders

Construction helpers for trusted, in-process callers — tests, fixtures, and the SDK's scaffolding. They parse their string arguments eagerly and throw on a malformed literal, because a bad primitive type here is a typo in source rather than untrusted input. Data arriving from the wire or from AI goes through parseTree / parseDelta, which never throw.

ElementSpectype

type ElementSpec = {
    readonly type: string;
    readonly props?: JsonObject;
    readonly children?: readonly LoomNode[];
};

buildElementfunction

Shown in use on Your first tree, Children and slots — Children: order is the meaning and Starting from a band — It goes on the page as one insert

const buildElement: (idFactory: IdFactory, spec: ElementSpec) => ElementNode

buildTextfunction

Shown in use on Your first tree

const buildText: (idFactory: IdFactory, value: string) => TextNode

buildSlotfunction

Shown in use on Your first tree — Three kinds of node and Children and slots — Slots: the parent decides where

const buildSlot: (idFactory: IdFactory, name: string, fallback?: readonly LoomNode[]) => SlotNode

Comparing two trees

tree/compare

Why two trees are not the same tree.

NodeFacettype

What differs about a node both trees have. Ordered as listed.

type NodeFacet = "kind" | "type" | "name" | "props" | "text" | "parent" | "position";

TreeDifferencetype

type TreeDifference = 
/** Present in the base tree, absent from the compared one. */
{
    readonly code: "missing";
    readonly nodeId: NodeId;
    readonly label: string;
}
/** Present in the compared tree, absent from the base one. */
 | {
    readonly code: "extra";
    readonly nodeId: NodeId;
    readonly label: string;
} | {
    readonly code: "changed";
    readonly nodeId: NodeId;
    readonly label: string;
    /** Never empty — an unchanged node produces no difference at all. */
    readonly facets: readonly NodeFacet[];
};

compareTreesfunction

Every way compared differs from base, one entry per node.

Shown in use on When something looks wrong — Is the page still what its history says?

const compareTrees: (base: LoomTree, compared: LoomTree) => readonly TreeDifference[]

Configuration

tree/configuration

configure is one operation across three node kinds, so each kind exposes its settable surface as a plain JSON record:

configurationOffunction

const configurationOf: (node: LoomNode) => JsonObject

NodeConfigurationPatchtype

type NodeConfigurationPatch = {
    readonly set: JsonObject;
    readonly unset: readonly string[];
};

configureNodefunction

const configureNode: (node: LoomNode, patch: NodeConfigurationPatch) => Result<LoomNode, TreeError>

Delta

tree/delta

A TreeDelta is the only way a tree changes.

insertOperationSchemaschema

const insertOperationSchema: z.ZodObject<…>

removeOperationSchemaschema

const removeOperationSchema: z.ZodObject<…>

moveOperationSchemaschema

const moveOperationSchema: z.ZodObject<…>

configureOperationSchemaschema

const configureOperationSchema: z.ZodObject<…>

treeOperationSchemaschema

const treeOperationSchema: z.ZodDiscriminatedUnion<…>

InsertOperationtype

type InsertOperation = {
    readonly op: "insert";
    readonly parentId: NodeId;
    readonly index: number;
    readonly node: LoomNode;
};

RemoveOperationtype

type RemoveOperation = {
    readonly op: "remove";
    readonly nodeId: NodeId;
};

MoveOperationtype

type MoveOperation = {
    readonly op: "move";
    readonly nodeId: NodeId;
    readonly parentId: NodeId;
    readonly index: number;
};

ConfigureOperationtype

type ConfigureOperation = {
    readonly op: "configure";
    readonly nodeId: NodeId;
    readonly set: JsonObject;
    readonly unset: readonly string[];
};

TreeOperationtype

type TreeOperation = InsertOperation | RemoveOperation | MoveOperation | ConfigureOperation;

TreeOperationNametype

type TreeOperationName = TreeOperation["op"];

TREE_OPERATIONSvalue

The whole vocabulary of structural change, in the order the doc comment above argues it.

const TREE_OPERATIONS: readonly TreeOperationName[]

treeDeltaSchemaschema

const treeDeltaSchema: z.ZodObject<…>

TreeDeltatype

Shown in use on Introduction — Four operations, and no fifth

type TreeDelta = {
    readonly deltaId: DeltaId;
    readonly treeId: TreeId;
    readonly baseRevision: number;
    readonly operations: readonly TreeOperation[];
};

parseDeltafunction

The boundary parse for deltas arriving from AI interpretation or the wire.

const parseDelta: (input: unknown) => Result<TreeDelta, TreeError>

Errors

tree/errors

What a tree operation says when it refuses.

TreeErrortype

Every way a tree operation can legitimately fail. Data only — rendering a message is a separate concern so hosts can localise or structure it however they like.

type TreeError = {
    readonly code: "node-not-found";
    readonly nodeId: NodeId;
} | {
    readonly code: "duplicate-node-id";
    readonly nodeId: NodeId;
}
/** An id this delta removed, re-inserted as a different node. */
 | {
    readonly code: "recycled-node-id";
    readonly nodeId: NodeId;
} | {
    readonly code: "not-a-container";
    readonly nodeId: NodeId;
    readonly nodeKind: NodeKind;
} | {
    readonly code: "index-out-of-range";
    readonly parentId: NodeId;
    readonly index: number;
    readonly childCount: number;
} | {
    readonly code: "move-into-self";
    readonly nodeId: NodeId;
} | {
    readonly code: "move-into-descendant";
    readonly nodeId: NodeId;
    readonly parentId: NodeId;
} | {
    readonly code: "root-not-detachable";
    readonly nodeId: NodeId;
} | {
    readonly code: "unconfigurable-key";
    readonly nodeId: NodeId;
    readonly nodeKind: NodeKind;
    readonly key: string;
} | {
    readonly code: "invalid-configuration";
    readonly nodeId: NodeId;
    readonly nodeKind: NodeKind;
    readonly detail: string;
} | {
    readonly code: "revision-mismatch";
    readonly expected: number;
    readonly actual: number;
} | {
    readonly code: "tree-mismatch";
    readonly expected: TreeId;
    readonly actual: TreeId;
} | {
    readonly code: "schema-violation";
    readonly path: string;
    readonly detail: string;
};

describeTreeErrorfunction

const describeTreeError: (error: TreeError) => string

Identity

tree/identity

Whether an id still names one thing.

nodeFingerprintfunction

A node's shape, ids included, at one moment.

const nodeFingerprint: (node: LoomNode) => string

Occupanttype

Who was living at an id, and what a reader would call them.

type Occupant = {
    readonly label: string;
    readonly fingerprint: string;
};

IdTenancytype

One unbroken stretch of an id being present in the tree.

type IdTenancy = {
    readonly enteredAt: number;
    readonly entered: Occupant;
    /** null while the node is still in the tree. */
    readonly leftAt: number | null;
    readonly left: Occupant | null;
};

IdHistorytype

Every stretch every id has had, oldest first.

type IdHistory = ReadonlyMap<NodeId, readonly IdTenancy[]>;

seedIdHistoryfunction

The history of a tree nobody has changed yet: every id present in it arrived at its revision. Usually revision 0, but a caller auditing from a later known state gets an honest starting point rather than a fictional one.

const seedIdHistory: (seed: LoomTree) => IdHistory

trackIdsfunction

Extends a history by one revision, from the two states either side of it.

const trackIds: (history: IdHistory, before: LoomTree, after: LoomTree) => IdHistory

IdReturntype

An id that left the tree and came back.

type IdReturn = {
    readonly code: "restored";
    readonly nodeId: NodeId;
    readonly label: string;
    readonly leftAt: number;
    readonly returnedAt: number;
} | {
    readonly code: "recycled";
    readonly nodeId: NodeId;
    readonly leftAs: string;
    readonly returnedAs: string;
    readonly leftAt: number;
    readonly returnedAt: number;
};

idReturnsInfunction

Every return in a history, in the order they happened.

const idReturnsIn: (history: IdHistory) => readonly IdReturn[]

recycledIdsfunction

The returns that make an id ambiguous, which is the subset worth acting on.

const recycledIds: (returns: readonly IdReturn[]) => readonly IdReturn[]

Undoing a delta

tree/inverse

Inverse deltas.

invertOperationfunction

const invertOperation: (root: LoomNode, operation: TreeOperation) => Result<TreeOperation, TreeError>

invertOperationsfunction

The operations that undo delta, without deciding which revision they apply to. Fails for the same reasons applying delta would fail, so a successful inversion also proves the original delta is applicable.

const invertOperations: (tree: LoomTree, delta: TreeDelta) => Result<readonly TreeOperation[], TreeError>

invertDeltafunction

The delta that undoes delta, targeting the revision delta produces.

const invertDelta: (tree: LoomTree, delta: TreeDelta, inverseDeltaId: DeltaId) => Result<TreeDelta, TreeError>

Changing a tree

tree/mutation

Structural edits on an immutable tree. Each returns a new root; nodes off the edited path keep their identity, so a renderer can rely on reference equality to skip untouched subtrees.

NodeTransformtype

type NodeTransform = (node: LoomNode) => Result<LoomNode, TreeError>;

replaceNodefunction

const replaceNode: (root: LoomNode, nodeId: NodeId, transform: NodeTransform) => Result<LoomNode, TreeError>

insertChildfunction

const insertChild: (root: LoomNode, parentId: NodeId, index: number, node: LoomNode) => Result<LoomNode, TreeError>

DetachedTreetype

type DetachedTree = {
    readonly root: LoomNode;
    readonly detached: LoomNode;
};

detachNodefunction

const detachNode: (root: LoomNode, nodeId: NodeId) => Result<DetachedTree, TreeError>

What to call a node

tree/naming

Which nodes a set of operations names, read from the operations alone.

namedNodeIdsfunction

const namedNodeIds: (operations: readonly TreeOperation[]) => readonly NodeId[]

Navigation

tree/navigation

Read-only traversal. Every function here takes a root and derives a view; nothing caches, because a cache would be a second source of truth about a structure that changes on every accepted delta.

walkTreefunction

function walkTree(root: LoomNode): Generator<LoomNode>;

collectNodeIdsfunction

const collectNodeIds: (root: LoomNode) => readonly NodeId[]

findNodefunction

const findNode: (root: LoomNode, nodeId: NodeId) => LoomNode | null

containsNodefunction

const containsNode: (root: LoomNode, nodeId: NodeId) => boolean

findParentfunction

const findParent: (root: LoomNode, nodeId: NodeId) => LoomNode | null

nodePathfunction

The chain of nodes from the root down to and including nodeId, or null when the node is absent. Paths are derived on demand and never persisted — the id is the stable address, the path is the current position.

const nodePath: (root: LoomNode, nodeId: NodeId) => readonly LoomNode[] | null

pathToNodefunction

The same chain as ids, for callers that only need the address.

const pathToNode: (root: LoomNode, nodeId: NodeId) => readonly NodeId[] | null

isDescendantOffunction

const isDescendantOf: (root: LoomNode, ancestorId: NodeId, nodeId: NodeId) => boolean

textOffunction

What a node says: every text node under it, in tree order, concatenated.

const textOf: (root: LoomNode) => string

duplicateNodeIdsfunction

Ids that appear more than once anywhere under root.

const duplicateNodeIds: (root: LoomNode) => readonly NodeId[]

Nodes

tree/node

The component AST.

ElementNodetype

type ElementNode = {
    readonly kind: "element";
    readonly id: NodeId;
    readonly type: PrimitiveType;
    readonly props: JsonObject;
    readonly children: readonly LoomNode[];
};

TextNodetype

type TextNode = {
    readonly kind: "text";
    readonly id: NodeId;
    readonly value: string;
};

SlotNodetype

type SlotNode = {
    readonly kind: "slot";
    readonly id: NodeId;
    readonly name: SlotName;
    readonly children: readonly LoomNode[];
};

LoomNodetype

type LoomNode = ElementNode | TextNode | SlotNode;

NodeKindtype

type NodeKind = LoomNode["kind"];

loomNodeSchemaschema

const loomNodeSchema: z.ZodType<…>

elementNodeSchemaschema

const elementNodeSchema: z.ZodObject<…>

textNodeSchemaschema

const textNodeSchema: z.ZodObject<…>

slotNodeSchemaschema

const slotNodeSchema: z.ZodObject<…>

isContainerNodefunction

Text is a leaf; every other kind holds an ordered child list.

const isContainerNode: (node: LoomNode) => node is ElementNode | SlotNode

childrenOffunction

const childrenOf: (node: LoomNode) => readonly LoomNode[]

nodeLabelfunction

The node's own name, which is the only thing a reader recognises it by. Ids are minted and carry no meaning, so anything reported to a person names the node as well as addressing it.

const nodeLabel: (node: LoomNode) => string

withChildrenfunction

const withChildren: <TNode extends ElementNode | SlotNode>(node: TNode, children: readonly LoomNode[]) => TNode

The outline a reviewer reads

tree/outline

The tree, flattened into the reading order a list renders in.

OutlineEntrytype

type OutlineEntry = {
    readonly node: LoomNode;
    /** 0 at the root. */
    readonly depth: number;
    /** null at the root. */
    readonly parentId: NodeId | null;
    /** Position among the parent's children; 0 at the root. */
    readonly index: number;
};

outlineTreefunction

Pre-order, so a row is always preceded by the row it is nested inside.

const outlineTree: (root: LoomNode) => readonly OutlineEntry[]

Trees

tree/tree

The persisted document. A tree is a root element plus a revision counter — no timestamps, because minting one is a side effect and every function in this module is pure. Storage and telemetry stamp their own times.

TREE_SCHEMA_VERSIONvalue

const TREE_SCHEMA_VERSION = 1

loomTreeSchemaschema

const loomTreeSchema: z.ZodObject<…>

LoomTreetype

Shown in use on Your first tree — Builders, or a parse, Making it look like yours — Putting it on a page, Proposing a change — Who writes the plan and Connecting a model — The slot, and what goes in it

type LoomTree = {
    readonly treeId: TreeId;
    readonly schemaVersion: typeof TREE_SCHEMA_VERSION;
    readonly revision: number;
    readonly root: ElementNode;
};

createTreefunction

The root is an element rather than any node because a document always has a container: a bare text or slot root has no place to insert into, which would make the first insert of every tree a special case.

Shown in use on Your first tree and Starting from a band — It goes on the page as one insert

const createTree: (root: ElementNode, idFactory: IdFactory) => LoomTree

withRootfunction

const withRoot: (tree: LoomTree, root: ElementNode) => LoomTree

validateTreeInvariantsfunction

Structural invariants Zod cannot express. Currently one: node ids are unique across the whole tree, since ids are the addressing scheme for every downstream operation.

Shown in use on Your first tree — Ids, and why you pass a factory

const validateTreeInvariants: (tree: LoomTree) => Result<LoomTree, TreeError>

parseTreefunction

The boundary parse: unknown input in, a validated tree or a TreeError out.

Shown in use on Your first tree — Builders, or a parse

const parseTree: (input: unknown) => Result<LoomTree, TreeError>