@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>>;
}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;
}) => PageEndscursorPositionfunction
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.
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>>;
}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[]
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>