Environments
The definition itself — what defineEnvironment takes, what the registry does with it, and the helpers a definition reads its own node tree with.
@internote/canvas-sdk#Functions
(node: NodeLike, identifier: string): unknown(node: NodeLike): Record<string, unknown>The RAW values, as the document carries them — a keyframe track arrives here as a track. For the momentary value at this point on the timeline, ask the runtime (ctx.animation.attrValuesOf), which resolves tracks against the step position first.
(value: unknown): unknown[]canvas.<name> environment nodes and their vocabularies, admits the environment nodes into the scene detail, and widens the cue family's child policies to the registered vocabularies (cues wrap the elements they animate).(base: ChalkLibrary): ChalkLibrarycanvas.<name> nodes, their vocabularies, and the widened scene-detail and cue-family child policies.(): ChalkLibrary(nodeIdentifier: string, attrIdentifier: string): CanvasAttrInfo | nullNull when the node is not canvas-registered or does not declare the attribute. Includes the universal continue, with its targets resolved against the registry. Drives post-compile refinement and animatability decisions.
(nodeIdentifier: string): Record<string, CanvasAttrSpec> | nullgraph gives canvas.graph; the inverse of environmentNameOf.(environmentName: string): stringcontinue:'s targets resolved against the registry — they are the registered environment nodes, so they cannot be written down ahead of registration.(): Record<string, AttrSpec>(environmentName: string): string[]<N extends NodeLike = CompiledNode>(content: unknown): N[]Not Array.isArray alone: a typed body (D33) may be an array of plain values — !sizes{1, 2, 3} is [1.0, 2.0, 3.0] — which is not a list of nodes and must not be walked as if it were. An element is what carries an identifier.
!cue.to or !cue.zoom keyframes, a quick text check made before the slower splitCueChain.(value: unknown): value is stringbody or detail, at any depth — inside a cue, a scene, another environment.<N extends NodeLike>(root: N | readonly N[], match: NodeMatch<N>): boolean(def: EnvironmentDefinition): EnvironmentDefinitionexport default defineEnvironment({
name: 'graph',
vocabulary: { … },
Surface: GraphSurface,
});body and detail both, at any depth, cues and marks and nested environments alike — in document order, parents before their children.<N extends NodeLike>(root: N | readonly N[]): N[]canvas.graph gives graph, or null if the identifier is not an environment's.(identifier: string): string | nullundefined where none did and ../refine's own table answers instead.(typeName: string): AttrRefiner | undefinedbody and detail at any depth.<N extends NodeLike>(root: N | readonly N[], match: NodeMatch<N>): N | undefined!cue.to and !cue.zoom appears first in a value, for error messages that name it, or null if neither does.(value: string): string | null<N extends NodeLike>(children: readonly N[], match: NodeMatch<N>): N | undefined<N extends NodeLike>(children: readonly N[]): N[]graph, not canvas.graph).(name: string): EnvironmentSpec | undefined(node: NodeLike, identifier: string): boolean<N extends NodeLike>(children: readonly N[], match: NodeMatch<N>): boolean#ids of the marks the match names, cue wrappers unwrapped.<N extends NodeLike>(children: readonly N[], match: NodeMatch<N>): Set<string>(node: NodeLike): booleancanvas. prefix; registration is not checked.(identifier: string): boolean(identifier: string): boolean(node: NodeLike): booleanKeyframeTrack rather than a still value.(value: unknown): value is KeyframeTrackcue, cue.draw).(node: NodeLike): boolean(): EnvironmentSpec[]<N extends NodeLike>(node: N, match: NodeMatch<N>): boolean#id, cue wrappers unwrapped, optionally only among the kinds the match names.<N extends NodeLike>(
children: readonly N[],
id: string,
match?: NodeMatch<N>,
): N | undefined#id, or null when it has none — the test every addressable lookup starts with.(node: NodeLike): string | nullinstance_id, or the fallback when it has none (a node synthesised at render time, a test's).(node: NodeLike, fallback: string | number): string<N extends NodeLike>(children: readonly N[]): Map<string, N[]><N extends NodeLike>(children: readonly N[], match: NodeMatch<N>): N[](
attrs: Readonly<Record<string, unknown>>,
identifier: string,
fallback: number,
): number
(
attrs: Readonly<Record<string, unknown>>,
identifier: string,
): number | undefined(spec: EnvironmentSpec): void(type: unknown): { base: string; values?: string[]; } | nullscene, which is where an environment is written.<N extends NodeLike = NodeLike>(document: unknown, identifier: string): N[](input: string): SplitCueResult(
attrs: Readonly<Record<string, unknown>>,
identifier: string,
fallback: string,
): string
(
attrs: Readonly<Record<string, unknown>>,
identifier: string,
): string | undefinedEnvironmentDefinition the compiler needs, as an EnvironmentSpec.(def: EnvironmentDefinition): EnvironmentSpec(name: string): voidflatNodes, except that a presence cue which has not fired (or has been cued back out) takes its contents with it.<N extends NodeLike>(
children: readonly N[],
nodeVisible: (node: N) => boolean,
): N[]Anything that puts a CONTROL in front of the reader has to ask this rather than flatNodes, or a parameter written inside a cue hands out its slider from the first step while the mark it drives is still off the surface. The attention cues leave presence alone, so their contents are always here.
nodeVisible is the runtime's — ctx.animation.nodeVisible — so the answer is the one the anchors give and not a second reading of them.
#Classes
registerEnvironment when a spec is invalid or conflicts with one already registered.typeof EnvironmentRegistrationErrorCauses: a malformed name, a second spec under a taken name, redeclaring or tagging the universal continue, an implicit naming an undeclared attribute, or a child element another environment declares differently.
#Constants
canvas.graph.stringreadonly ["continue"]ReadonlySet<string>The partition matters more than either half. Every environment that walks its own body has to make it, and until 21 September 2026 each one made it privately from a hand-copied list of identifiers — so a sixth cue form would have been added here and silently not unwrapped by any of them.
readonly ["cue", "cue.draw", "cue.trace", "cue.highlight", "cue.spotlight"]cue.highlight runs than the duration: it was given.numberduration: is how long ONE ring takes, and the rings are staggered: the next sets off while the last is still on its way out, so the gesture as a whole outlasts a single ring. Shared because two places have to agree on it — the surface that draws the rings, and the host that measures how long the step's choreography runs so the transport knows when it is over.
ReadonlySet<string>!cue.to keyframe in an attribute value.string!cue.zoom keyframe in an attribute value.stringEnvironmentRegistryClassRegistering a definition serves both sides at once: the render-side definition becomes resolvable for hosts, and its compile-side slice is fed to the registry in @internote/canvas-sdk, where the library builder, the refiners and the semantic pass read it. A plugin arrives here through loadEnvironmentFromManifest.
step.readonly ["step"]#Interfaces
animatable!cue.to; absent when it cannot be animated.InterpolationKindtypeTypeRefSpecaliasesstring[]animatable!cue.to; absent means the attribute cannot be animated.InterpolationKinddefaulttype; arrays/numbers/booleans are stringified first. Defaults for inline/record/reference types are written in SOURCE syntax as strings (e.g. '!(x: 1)').unknownrequiredrequired, and nothing else — no type means "or nothing".booleantypestring. A default of the most permissive type is what an unconsidered attribute receives by accident, making it indistinguishable from a deliberately textual one. The rule governs the EFFECTIVE merged attribute, so refining a primitive's attribute need not restate its type.TypeRefSpecaliasesstring[]attributesRecord<string, CanvasAttrSpec>bodyPolicySpecallclassesstring[]delimiter---.stringderivedRecord<string, DerivedSpec>detailPolicySpecnonedynamicSwitchSpec[]implicit:value slot supplies (D37) — a canonical name or an alias, stored canonical. WITHOUT it the construct has no : slot and image:photo.jpg is a parse error naming the (href: …) spelling. It must name a settable, non-derived attribute that no dynamic case withdraws. On a merge the tag is kept unless restated; null removes a primitive's.string | null!cue.zoom keyframe's box — the rectangle drawn around the smaller of the two windows before the frame moves.colourstringdrawMsnumberholdMsnumberatCueAnchorbox!cue.zoom: the move is announced by a box.CueBoxdelayMs!cue.zoom spends it drawing its box; nothing else uses it, and an absent delay is the ordinary keyframe that leaves immediately.numbereasingoverMs.linearsmoothoverMscueToDefaults) into refined frames; optional only for hand-built trees.numbervalueunknownnodeAnimationMsanchorAttr (in, out, at), in ms — for a verb whose window is not something the author wrote down but something derived from its own content.(node: CompiledNode, anchorAttr: string) => number | nullThe host measures the shared cue family off its attributes: a fade is in-duration:, a verb's move is duration:. A type verb whose window is its content's own size has no such attribute to read, and measured that way it comes out as the fallback — cue.type types for as long as its text takes at speed: characters a second, and a transport measuring attributes alone called a ten-second typing finished in a fraction of a second. Return null to leave the generic rule standing.
Read from the compiled node, like timeAnimationMs: the host asks once, before any environment has mounted, so there is no session and no runtime.
onStep(index: number, ctx: EnvironmentContext) => voidtimeAnimationMs(node: CompiledNode) => numberThe scene's clock is what the host's transport drives, and its length is otherwise measured only from time-anchored cues. A type whose content moves through time for its own reasons declares it here, so the reader gets the SAME play control rather than a second one beside it. Read from the compiled node: no session, no datasets, because the host asks before any environment has mounted.
verbsRecord<string, VerbHandler<EnvironmentContext>>animationEnvironmentAnimationRuntime<EnvironmentContext>assetscanvas.diagram:cell.jpg) resolves its name against. Binary files never arrive as content; a URL is all a surface needs.Record<string, string> | nullnull means the manifest has not arrived yet, which a type must be able to tell from {} — one is a wait, the other an absence.
datasets@data datasets, by id — what a vocabulary attribute binding a column (values: #gdp.gdppc) resolves against. Blank-canvas content already receives these; environments read them here.Record<string, ChalkDataset>environmentNamegraph, not canvas.graph).stringfiles.geojson, a .part.chalk include. Attached CSVs are already folded into datasets and are not read from here; this is for attachments whose shape is not a table, which the type that understands them parses itself.Record<string, string> | nullnull means the manifest has not arrived yet, which a type must be able to tell from {} — one is a wait, the other an absence.
footerFooterAccessidentityEnvironmentIdentityisFocusedbooleanisPresentingbooleannodecanvas.<name> node.CompiledNoderunnerCodeRunnerstepnumberstepAnimationRemainingMsnumberstepCountnumberstepsStepControllerstorageCanvasStorageThe compile side (environment attributes and child vocabulary, validated by the compiler and the semantic pass) and the render side (surface, animation handlers, toolbar, footer, storage) live in the one definition. First-party environments and plugins author the identical shape; only how the definition reaches the EnvironmentRegistry differs.
An environment never reaches around the APIs: JSX enters exactly twice (Surface, and the two bands of footer), the toolbar is data, animation handlers react to the engine, and storage goes through the declared tiers. Build one with defineEnvironment.
animationEnvironmentAnimationattributesid/continue.Record<string, CanvasAttrSpec>bodyPolicySpeccompileEnvironmentSpecFirst-party environments keep theirs React-free so server compiles see them; passing the same object here makes registration idempotent across both sides.
displayNamestringfooter{
Bar: ComponentType<EnvironmentContext>;
Panel?: ComponentType<EnvironmentContext>;
present?: (ctx: EnvironmentContext) => boolean;
hasPanel?: (ctx: EnvironmentContext) => boolean;
}Content only — the frame, the rule between the bands, the chevron and the drag handle are the host's, and there is one set of them for the scene however many environments contribute. Two components rather than one because only the panel animates, and the bar's contents are needed without mounting the panel's.
Either may render nothing; a footer with nothing in either band collapses altogether.
interpolationsRecord<string, Interpolator>An environment could already declare an attribute's animatable kind but had no way to say how two of that kind blend, so a new shape had nowhere to go but the SDK's built-in registry — which is how the graph's plane transform came to be a default here, cited by filename in a package that is supposed to know nothing about graphs. The registry always documented itself as "the whole extension point for new value shapes"; this is the door.
Registering a kind the SDK already defines replaces it for every environment in the host, so an environment names its own kinds.
namecanvas.graph.stringstorageCanvasStorageDeclarationSurfaceComponentType<SurfaceProps>toolbar(ctx: EnvironmentContext) => ToolbarItem[]vocabularyRecord<string, CanvasNodeSpec>attributescontinue.Record<string, CanvasAttrSpec>bodynone when there is no vocabulary.PolicySpecdynamicbody replaces the vocabulary policy wholesale.SwitchSpec[]implicit:value slot supplies (D37) — canvas.code:python tags language. An environment that omits it has no : slot. It may never be the universal continue, which is a reference to another environment and not this one's subject.stringnamestringtypesRecord<string, AttrRefiner>validatevoidvalueTypestypes table exactly as EnvironmentSpec.vocabulary is folded into its nodes.Record<string, TypeSpec>vocabularyRecord<string, CanvasNodeSpec>baseunknownframesCueKeyframe[]kind'keyframes'ChalkElements.attributesReadonlyArray<{ identifier: string; value: unknown }>body{…} section: child nodes, inline content or a typed value.unknowndetail[…] section: child nodes or inline content.unknownid#id the author gave the node, if any; read it with nodeId.unknownidentifiercanvas.graph, point or cue.stringinstance_idid, an author cannot write or reference it.unknownatat: anchor as written; required, so the splitter fails a frame without one.stringcolour!cue.zoom only: the palette colour the box is drawn in (color: is an alias).stringdraw!cue.zoom only: how long the box takes to draw, as written.stringeasingeasing: curve as written.stringhold!cue.zoom only: the pause before the frame moves, as written (pause: is an alias).stringkindzoom frames carry the box attributes.tozoomoverover: transition duration as written.stringvalue{…} body — the target value's source text.stringEnvironmentSpec.validate is handed: somewhere to report to, and the document facts a rule needs that the node itself does not carry.datasets@data datasets by id — what a data: #id binding resolves against.Record<string, unknown>reporterror fails the compile; a warning rides along with it.voidstepCountnumberctxEnvironmentContextrevisionnumber#Types
(input: string) => { ok: true; data: unknown } | { ok: false; error: string }at:: at a numbered step, optionally with a delay after it, or at a time in the scene.{
kind: "step";
value: number;
offsetMs?: number;
} | {
kind: "time";
value: number;
}'to' for !cue.to, 'zoom' for !cue.zoom.tozoomstring | ReadonlySet<string> | readonly string[] | ((node: N) => boolean)error fails the compilation, a warning rides along with a successful one.errorwarningsplitCueChain: the base value's text and its raw keyframes, or why the chain is malformed.{
ok: true;
base: string;
frames: RawCueFrame[];
} | {
ok: false;
error: string;
}