@deviltea/widget-core
@deviltea/widget-core is a renderer-agnostic widget composition core. It defines the cooperating WidgetPlugin, WidgetSystem, WidgetSystemBlueprint, WidgetDocument, and WidgetSystemRuntime layers. Persistence versioning, metadata, and rendering live outside the core; source editing is limited to the revisioned WidgetDocument and its JSON-domain SourcePatch contract.
This guide covers the full public surface. For installation and a minimal end-to-end example, see the package README. Cross-cutting Diagnostic/Result/Failure/Error conventions are maintained in the Widget API conventions.
Core model
WidgetPlugin
plugin semantic contract, built with createWidgetPlugin(type)
WidgetSystem
instance-scoped, immutable registered plugin universe
WidgetSystemBlueprint
immutable compiled semantic snapshot of one unknown/raw widget tree
status: 'valid' | 'invalid'
WidgetDocument
revisioned source + Blueprint coordinator with atomic SourcePatch commits
WidgetSystemRuntime
executable instance of a valid BlueprintcreateWidgetPlugin(type) -> builder chain -> WidgetPlugin
createWidgetSystem({ plugins, validateStructure? }) -> WidgetSystem
system.createBlueprint(source: unknown) -> WidgetSystemBlueprint
blueprint.createRuntime(options?) -> WidgetSystemRuntime // only when status === 'valid'Ownership rules that shape everything below:
WidgetSystemis instance-scoped and immutable; the registered plugin tuple defines that instance's TypeScript universe. There is no global/module augmentation, and a duplicateplugin.typeis rejected at construction.- A Blueprint is an immutable semantic snapshot. Editing a widget tree means compiling different source through
system.createBlueprint()orblueprint.recompile(), never mutating a Blueprint in place. - A Runtime can only be created from a Blueprint whose
statusis'valid'. - Runtime state is never part of a persisted/raw widget definition.
- Runtime-changing interactions (reading another widget's state/property, invoking another widget's method) are only reachable through dependencies declared with
registerDeps. - Properties are transitively side-effect free: a property may read state, read other properties, and invoke methods, but only methods that are themselves transitively read-only.
alien-signalsowns dependency tracking, laziness, caching, invalidation, and batching. The core only defines the observable semantics layered on top of it.
Defining a plugin
Capability interfaces
A plugin's shape is declared once, as a WidgetInterfaces:
import type { JsonValue } from '@deviltea/widget-core'
interface WidgetInterfaces {
config?: { raw: Record<string, JsonValue>, resolved: Record<any, any> }
slots?: string
state?: Record<any, any>
properties?: Record<any, any>
methods?: Record<any, (...args: any[]) => any>
}RawConfig is authored JSON data: its values must stay within JsonValue (including nested arrays and objects with string keys). Resolved config is a runtime domain and may use richer values.
Every section is a capability:
- absent (the key is not declared as a required property) → that capability does not exist, and its builder phase is skipped entirely
- present (the key is declared as a required property) → the builder requires that capability to be implemented completely
Presence is a declaration fact, independent of whether the capability's own member/name domain is empty. slots: never (zero declared slot names), state: Record<never, never> (zero declared state keys) and their properties/methods equivalents are all present, explicitly-empty capabilities — distinct from not declaring the section at all — and each still requires (and exposes) its builder phase, completed with an empty argument (.slots({}), .state(state => state), ...).
There is no pluginConfig / globalConfig. Plugin-specific integration options are ordinary factory options captured by closure around createWidgetPlugin(...), not a framework-owned config layer.
Reading capability presence at runtime: plugin.capabilities
A completed WidgetPlugin exposes its own declaration-presence facts as a plain, immutable object — renderer-agnostic core metadata, not an inspection/DevTools API:
const plugin = createWidgetPlugin('counter')
.description('A counter widget')
.done()
plugin.capabilities
// { config: boolean, slots: boolean, state: boolean, properties: boolean, methods: boolean }A consumer that already holds the exact plugin object (for example a renderer adapter's useWidget(plugin)) reads plugin.capabilities for capability presence instead of inferring it from Blueprint object shape or importing @deviltea/widget-core/inspection. The shape intentionally matches the inspection subpath's BlueprintInspectionCapabilities, but no object-identity relationship between the two is part of the contract.
Plugin, config, and slot descriptions are required intrinsic metadata. system.catalog is an immutable passive projection of the registered widget types and their capability descriptions; it contains no Blueprint or Runtime state.
Builder phase order
createWidgetPlugin('counter')
.description('A counter widget')
.interfaces<CounterInterfaces>()
.config({ /* ... */ }) // only if `config` is declared
.slots({ /* ... */ }) // only if `slots` is declared
.state(state => state /* ... */) // only if `state` is declared
.properties(properties => properties /* ... */) // only if `properties` is declared
.methods(methods => methods /* ... */) // only if `methods` is declared
.done()Each phase only exists on the builder chain when its capability was declared, so a plugin with no slots never exposes a .slots() step.
state, properties, and methods are not object-map sections. Each one is a keyed-chain builder: every declared member key becomes a chainable method on the section object, and calling it consumes that key. The section callback can only return once every declared key has been called exactly once — there is no .define(), .add(), or a framework-owned .done() for these three sections specifically (only the outer builder has .done()).
createWidgetPlugin('example')
.description('An example widget')
.interfaces<ExampleInterfaces>()
.state(state =>
state
.count({ /* ... */ })
.enabled({ /* ... */ }),
)Config
interface BadgeInterfaces {
config: {
raw: { label?: string }
resolved: { label: string }
}
}
createWidgetPlugin('badge')
.description('A badge widget')
.interfaces<BadgeInterfaces>()
.config({
description: 'Badge configuration',
validate: (input): input is { label?: string } =>
typeof input === 'object' && input !== null,
resolve: rawConfig => ({ label: rawConfig?.label ?? 'Badge' }),
})
// ...resolveis required and has no Blueprint/topology access — it is pure normalization from raw to resolved config.- Raw config presence is own-property based: an omitted raw
configfield resolves asresolve(null). - An explicitly present but invalid raw config produces a Blueprint
configdiagnostic, the typed raw config becomes unavailable, and the semantic config still resolves throughresolve(null)so the invalid Blueprint stays inspectable. validateis an authoritative predicate (input is RawConfig), not a coercion step.
Slots
Unlike state / properties / methods, slots is supplied as one complete object map — every declared slot key must be present:
interface ContainerInterfaces {
slots: 'header' | 'content'
}
createWidgetPlugin('container')
.description('A container widget')
.interfaces<ContainerInterfaces>()
.slots({
header: {
description: 'Header widgets',
validateStructure: ({ children, addDiagnostic }) => {
if (children.length > 1)
addDiagnostic({ message: 'header accepts at most one widget', index: 1 })
},
},
content: { description: 'Content widgets' },
})
// an optional second argument adds a plugin-level validateStructure
// ...- A resolved node's semantic
.slotsmap always contains exactly the declared slots, each a complete (possibly empty) array — an omitted or malformed raw slot resolves to[]. - Structural validation runs slot-level → plugin-level → system-level (see Compiling a blueprint).
- Unknown raw slot keys are excluded from the semantic slot map but remain navigable through the Blueprint's recovered source topology.
State
createWidgetPlugin('counter')
.description('A counter widget')
.interfaces<CounterInterfaces>()
.state(state =>
state.count({
validate: (input): input is number => typeof input === 'number',
default: () => 0,
}),
)validateis the authoritative predicate for every candidate: initial defaults,overrideStateDefaults, and everyRuntimeState.set()call.defaultis optional; when absent, state starts asnulluntil a successful write. Its declared return type is not runtime authority — the produced value is still run throughvalidate.
Properties
createWidgetPlugin('counter')
.description('A counter widget')
.interfaces<CounterInterfaces>()
.properties(properties =>
properties.doubled({
registerDeps: ({ dep }) => ({
count: dep.self.state.get('count'),
}),
compute: ({ deps }) => {
const count = deps.count()
return (count.ok ? count.value ?? 0 : 0) * 2
},
}),
)registerDepsis optional and runs once per Blueprint snapshot (not per runtime evaluation); recompiling produces a new Blueprint and may register a different dependency graph.computereceives the materializeddeps(executed dependency callables), the current widget's Blueprint node, a valid-Blueprint view, and an diagnostic collector.- A property's dependency grammar has no
state.setat the type level — properties cannot write state, directly or transitively.
Methods
createWidgetPlugin('counter')
.description('A counter widget')
.interfaces<CounterInterfaces>()
.methods(methods =>
methods
.increment({
validateArgs: (args): args is [number] =>
args.length === 1 && typeof args[0] === 'number',
registerDeps: ({ dep }) => ({
count: dep.self.state.get('count'),
setCount: dep.self.state.set('count'),
}),
execute: ({ args: [step], deps }) => {
const current = deps.count()
const value = (current.ok ? current.value ?? 0 : 0) + step
deps.setCount(value)
return value
},
})
.reset({
validateArgs: (args): args is [] => args.length === 0,
registerDeps: ({ dep }) => ({ setCount: dep.self.state.set('count') }),
execute: ({ deps }) => { deps.setCount(0) },
}),
)validateArgsis required for every method, including zero-argument methods (args is []), and is the authoritative boundary for the invocation's arguments.- Method dependency registration never binds invocation arguments — a registered
methods.invoke('name')dependency is a callable that accepts runtime arguments when the consumer calls it, validated by the target method's ownvalidateArgs. - Unlike properties, a method's dependency grammar includes
state.set.
Dependency grammar
registerDeps receives a dep builder. Its return value (RegisteredDeps) may be an arbitrary readonly object/array/tuple whose leaves are dependency expressions — the container shape is free organization, not a static data transport.
| Target | Existence | Typing | .optional() |
|---|---|---|---|
dep.self | guaranteed | precise, capability-aware for the current plugin | not applicable |
dep.root | guaranteed | unknown (refine with .validate()) | not applicable |
dep.parent | may be absent | unknown | available at the target stage |
dep.widget(id) | may be absent | unknown | available at the target stage |
Operations available per target, by consumer:
| Operation | Property consumer | Method consumer |
|---|---|---|
state.get(key) | yes | yes |
state.set(key) | no (not on the type surface) | yes |
properties.get(name) | yes | yes |
methods.invoke(name) | yes, only if the target method is transitively read-only | yes |
dep.parent
.optional()
.properties.get('label')
.validate((value): value is string => typeof value === 'string').optional()only changes target existence; it never suppresses ambiguity, an unresolved target, a missing capability/member, a refinement failure, or a target operation failure.- When an optional target is absent at runtime:
state.get/property.get/method.invokesucceed withnull(skipping.validate()entirely), andstate.setis a silent no-op that still succeeds with the original candidate — neverT | null. .validate()is available only on readable leaves (state.get,property.get,method.invoke's return value), is pure and repeatable, and only narrows; it is never available onstate.setor on method invocation arguments.- Reads/invokes materialize as zero/variadic-argument callables returning an
ExecutionResult; a target's own failure is wrapped one-to-one into a consumer-localdependency-target-failed/dependency-value-rejecteddiagnostic rather than exposed as the target's own diagnostic type.
Compiling a blueprint
Compilation boundary and recovery
const blueprint = system.createBlueprint(source) // source: unknownThe input is unknown on purpose — JSON-parsed or otherwise untrusted document data is the real boundary. Compilation never throws merely because the input is malformed; it always produces an inspectable Blueprint through best-effort recovery, down to a recovered root.
A node becomes resolved once its WidgetId, plugin type, and a matching registered plugin are all established. After that point, config, slot, structure, or dependency errors keep the node resolved while the Blueprint as a whole becomes invalid — only a genuinely unrecoverable identity (missing/invalid id or type, unknown plugin type) produces an unresolved node.
Navigation works on both valid and invalid Blueprints:
blueprint.root
blueprint.getWidget(id)
blueprint.getParent(node)
blueprint.getLocation(node)
blueprint.getChildren(node)
blueprint.getChildrenAt(node, slot)
blueprint.diagnosticsgetChildren / getChildrenAt walk the recovered source topology; a resolved node's .slots field is the separate, complete semantic topology (only declared slots, every declared slot present).
Diagnostics
Every diagnostic has top-level code, location, and message fields. Variant-specific facts are direct fields; structured path and related are not nested under a generic source object. Codes are stable lowercase kebab-case values.
code family | Owns |
|---|---|
invalid-widget-* / unknown-widget-type | malformed widget identity and authored shape |
invalid-widget-config | config.validate failures on a resolved node |
invalid-widget-structure | slot-level / plugin-level / system-level validateStructure failures |
json-incompatible-value | confirmed whole-source JSON-domain violations; carries a source-rooted path and closed framework reason |
source-access-failed | whole-source compatibility could not be safely proven for the source-rooted path |
*-dependency-* / property-evaluation-cycle | dependency target/member failures, Property purity violations, and Property-containing evaluation cycles |
for (const diagnostic of blueprint.diagnostics) {
console.log(diagnostic.code, diagnostic.location, diagnostic.message)
}Status and recompilation
type WidgetSystemBlueprintStatus = 'valid' | 'invalid'Only status === 'valid':
- narrows every node query to resolved nodes
- narrows
.diagnosticsto an empty array - exposes
createRuntime(options?)
sourceJsonCompatible is the authored JSON-domain proof. In its positive branch, blueprint.source and source fragments on all recovered navigation outputs carry JsonValue; the false branch keeps the exact source as unknown. False means either confirmed incompatibility or an unproven safe inspection; aggregate Blueprint diagnostics distinguish json-incompatible-value from source-access-failed. Source-level diagnostics are not copied onto node .diagnostics. A valid Blueprint requires semantic validity and this proof, so Runtime creation never promotes non-JSON authored material.
if (blueprint.status !== 'valid') {
// inspect blueprint.diagnostics — no createRuntime here
}
else {
const runtime = blueprint.createRuntime()
}Editing is recompiling, not mutating:
const next = blueprint.recompile(nextSource)
// observably equivalent to: blueprint.system.createBlueprint(nextSource)WidgetDocument and SourcePatch
createWidgetDocument({ system, source }) keeps the current source and its compiled Blueprint together behind a monotonically increasing revision. A SourcePatch uses one JSON-domain RFC6901/RFC6902 language: paths may be JSON Pointer strings or structured string/number segments, operands are JsonValue, and successful patches use copy-on-write reconstruction. Mechanical failure is atomic; a mechanically successful patch may still commit a semantically invalid Blueprint for inspection.
import { createWidgetDocument } from '@deviltea/widget-core'
const document = createWidgetDocument({
system,
source: { id: 'counter-1', type: 'counter' },
})
const result = document.applyPatch([
{ op: 'replace', path: '/config/step', value: 2 },
], { expectedRevision: 0 })
if (result.ok && result.changed)
console.log(document.getSnapshot().revision) // 1Explicit separated-source tooling
WidgetSource remains the canonical nested authored form. Core also exports an explicit SeparatedWidgetSource projection with a nested structure tree and a flat widgets data list. This representation is tooling only; Core never auto-detects it at the unknown Blueprint or Document recovery boundary.
import {
normalizeSeparatedWidgetSource,
separateWidgetSource,
} from '@deviltea/widget-core'
const separated = separateWidgetSource(canonicalSource)
const { source, diagnostics } = normalizeSeparatedWidgetSource(separated)The normalizer is deterministic, non-destructive, and best-effort. The first flat entry for a Widget ID wins; missing data creates a partial nested node; unused data is diagnosed rather than guessed into the tree; and repeated structural IDs retain their available data/subtree while the later occurrence is de-identified. These representation diagnostics are separate from Blueprint semantic diagnostics and carry structure or flat-widgets source locations.
Running a runtime
Creating a runtime
const runtime = blueprint.createRuntime({
overrideStateDefaults: {
'counter-1': { count: 10 },
},
})overrideStateDefaults is an initialization-only candidate map, not a patch API — it never mutates a running Runtime. Precedence per state member is override > plugin default > null, and every candidate (override or default) is still run through that member's validate. An invalid override never falls back to the plugin default; it leaves the state null with an ordinary invalid-state-value diagnostic. createRuntime never fails: malformed override input (unknown widget, unknown key, wrong shape) still produces a Runtime, recorded as Runtime-level unknown-state-override-* diagnostics.
runtime.getWidget(id) returns a RuntimeWidget | null distributed over the registered plugin tuple. When more than one plugin is registered, narrow on widget.type before accessing plugin-specific state / properties / methods — there is no getWidget(id, type) helper.
State
widget.state.count.get() // T | null — a direct read, not an ExecutionResult
widget.state.count.set(5) // ExecutionResult<number, RuntimeStateDiagnostic>
widget.state.count.subscribe(listener) // listener: (value: T | null) => void
widget.state.count.getDiagnostics()
widget.state.count.subscribeDiagnostics(listener)- An invalid
set()preserves the previously accepted value and returns{ ok: false, failure: { diagnostics } }; a successfulset()commits the candidate and clears the latest diagnostic snapshot. - Change detection is strict
!==: writingNaNagain still counts as changed (NaN !== NaNistrue), while+0and-0count as unchanged. - Subscriptions never emit immediately upon subscribing.
Properties
widget.properties.doubled.get() // ExecutionResult<T | null, RuntimePropertyDiagnostic>
widget.properties.doubled.subscribe(listener) // listener: (result) => void
widget.properties.doubled.getDiagnostics()
widget.properties.doubled.subscribeDiagnostics(listener)Properties are lazily evaluated and cached through alien-signals. subscribe() activates observation but never emits immediately; every actual completed recompute notifies exactly once, even when the recomputed value compares equal to the previous one. subscribeDiagnostics() observes a separate diagnostic channel and never triggers a property evaluation.
Methods
widget.methods.increment(5) // ExecutionResult<number, RuntimeMethodDiagnostic>
widget.methods.increment.getDiagnostics()
widget.methods.increment.subscribeDiagnostics(listener)Every invocation runs validateArgs before execute; on failure, execute never runs and the result carries an invalid-method-arguments diagnostic. A successful execute whose plugin/dependency diagnostics were still recorded becomes a failure with an invalid-method-result diagnostic attached — the returned value in that case is diagnostic context only, never a degraded ok value.
Every RuntimeMethod invocation opens a batch boundary around its dependency/state writes (including dependency-invoked methods, which nest); only the outermost invocation flushes propagation. Batching means propagation atomicity, not a transaction: writes performed before a later failure or a thrown callback remain committed, and the batch still ends via finally.
Diagnostics
Every state write, property evaluation, and method invocation keeps only its latest completed operation's diagnostic snapshot — never a history. A callback throw discards the in-progress collector and preserves the previous completed snapshot; it is an implementation exception, not a Diagnostic and not an ExecutionResult.failure.
Every RuntimeWidget also exposes its own aggregate, present regardless of declared capabilities:
widget.getDiagnostics() // readonly RuntimeWidgetDiagnostic[]
widget.subscribeDiagnostics(listener)RuntimeWidgetDiagnostic is the union of only that widget's own primitive-owned diagnostics (RuntimeStateDiagnostic | RuntimePropertyDiagnostic | RuntimeMethodDiagnostic) — never a Runtime-level diagnostic, even when a diagnostic mentions the same widgetId. The snapshot order is deterministic: state members -> property members -> method members, plugin declaration order within each capability, and each primitive's own local diagnostic order preserved. A widget with no capabilities, or with every primitive currently succeeding, aggregates to [].
runtime.getDiagnostics() // complete aggregate: Runtime-level, then RuntimeWidget snapshots in Blueprint order
runtime.subscribeDiagnostics(listener)RuntimeWidget.getDiagnostics() / subscribeDiagnostics() and WidgetSystemRuntime.getDiagnostics() / subscribeDiagnostics() read diagnostic signals only and never activate property evaluation. The aggregate may legitimately list both a failing primitive's own diagnostic and a consumer's wrapped dependency diagnostic that points back at it — that is causal context, not duplication.
Disposal
runtime.dispose() // idempotentAfter disposal, runtime.isDisposed and runtime.blueprint remain readable, and previously obtained RuntimeWidget.id / .type / .blueprint stay readable. Every live query/operation (runtime.getWidget, state/property reads and writes, method invocations, RuntimeWidget.getDiagnostics()) and every new subscription (including RuntimeWidget.subscribeDiagnostics()) throws WidgetSystemRuntimeDisposedError; previously returned unsubscribe functions remain safe, idempotent no-ops. Disposal itself emits no notification and creates no diagnostic.
import { WidgetSystemRuntimeDisposedError } from '@deviltea/widget-core'
try {
runtime.getWidget('counter-1')
}
catch (error) {
if (error instanceof WidgetSystemRuntimeDisposedError) {
// `instanceof` is the stable discriminator; the message text is not.
}
}Inspection (DevTools)
@deviltea/widget-core/inspection is a dedicated, strictly readonly subpath for building inspectors/DevTools over the compiler and Runtime. It is not re-exported from the root entrypoint:
import { inspectBlueprint, inspectRuntime } from '@deviltea/widget-core/inspection'The inspection surface only ever reports facts the core already knows or already computed; it never sets State, invokes a Method, forces Property evaluation, or otherwise changes the semantic behavior being inspected — including when a subscription is active.
Blueprint inspection
const inspection = inspectBlueprint(blueprint)
inspection.rootNodeId // InspectionNodeId
inspection.nodes // every recovered source node, pre-order, including unresolved nodes
inspection.invalidCycles // Property-containing invalid evaluation SCCs
inspection.getNode(nodeId)
inspection.getNodeId(node) // null for a foreign/forged nodeinspectBlueprint(blueprint)is lazy opt-in but eager once called: the first call materializes one complete, frozen snapshot, cached per Blueprint instance (inspectBlueprint(blueprint) === inspectBlueprint(blueprint)). Building/reading it performs zero semantic execution.InspectionNodeIdis a snapshot-local diagnostic identity only — unique within one snapshot, valid for resolved and unresolved nodes alike, with no stability guarantee acrossrecompile()or separate snapshots. It is not a domain identifier and must never be used as a dependency target.- Each node carries the existing public
BlueprintWidgetNode(node) rather than a duplicate DTO, plussourceSlots(recovered source topology, each entry'splacementis'slot'when the raw slot key is plugin-declared and'raw-slot'otherwise — an unresolved node's slots are always'raw-slot', and a malformed raw slot value produces no entry at all). - A resolved node additionally carries
capabilities(declared vs explicitly-empty presence forconfig/slots/state/properties/methods),semanticSlots, andstate/properties/methodsmember inventories in declaration order. A method'stransitivelyWritesand the Blueprint'sinvalidCyclesare read directly off the existing compiler graph analysis — inspection never re-runs its own SCC or write-effect traversal. - Each
property/methodmember exposes its declared dependencies, flattened to{ path, reference, status, ... }facts:pathis the exact container path from theregisterDeps()return root (object keys stay strings verbatim, array/tuple indices stay numbers — never stringified).statusis'resolved'(with the semantically-resolvedtarget),'absent'(legal optional cardinality-0 target, no Diagnostic), or'invalid'(every other resolution failure — ambiguous/missing targets carry notargetNodeId; unique-but-unresolved-target/missing-capability/missing-member failures do). Status is always read from compiler-authoritative data, never reconstructed from Diagnostics; the existing Blueprint Diagnostic surface remains the authoritative human-readable explanation.
Runtime inspection
const inspection = inspectRuntime(runtime)
inspection.blueprint // === inspectBlueprint(runtime.blueprint)
inspection.getWidget(nodeId) // RuntimeWidgetInspection | nullconst widgetInspection = inspection.getWidget(nodeId)!
widgetInspection.getState('count')
?.getSnapshot() // { value: T | null }
widgetInspection.getProperty('doubled')
?.getSnapshot() // never-evaluated | completedgetState/getPropertyreturnnullonly when the member/capability does not exist; values are dynamicallyunknown-typed (strongly-typed access remains the job of the ordinary Runtime surface). There is deliberately no Method runtime inspection in v1 — nogetMethod, no invocation facts, no history.- Both
RuntimeStateInspection/RuntimePropertyInspectionfollow the same{ getSnapshot(), subscribe(listener) }shape:getSnapshot()is a passive read, andsubscribe()observes future changes only — no immediate emission, and never an event log. - Retained facts start at Runtime creation, independent of whether/when
inspectRuntime()is first called. State inspection reflects the current authoritative value, publishing a fresh{ value }snapshot only when a write actually changes it (a rejected write publishes nothing). Property inspection starts{ status: 'never-evaluated' }and publishes a fresh{ status: 'completed', result }on every naturally completed evaluation attempt (ok or semantic failure) — two equal completions still notify twice, and a callback throw or thenable violation never replaces the latest snapshot. - Reading or subscribing through inspection never activates a Property, never executes a Method, and never creates a tracked
alien-signalsdependency — even agetSnapshot()call made from inside an externaleffect/computedis an untracked read. - Already-retained facts (and not-yet-obtained member facades) stay readable after
runtime.dispose(), including the very firstinspectRuntime(runtime)call happening after disposal — this is deliberately different from the live Runtime surface. Onlysubscribe()is gated on disposal: a new subscription afterdispose()throwsWidgetSystemRuntimeDisposedError, while subscriptions made before disposal are silently detached (no final emission) and their unsubscribe functions stay safe/idempotent. Inspection retains no history, method calls, timestamps, or profiling data beyond the already-documented current/last facts.
Design constraints
- Synchronous boundary. Every framework-owned semantic callback (
validate,resolve,default,validateStructure,registerDeps,compute,validateArgs,execute) is synchronous. A returned Promise/thenable is a contract violation on the plugin author's side, not aDiagnosticand not anExecutionResult.failure. - Renderer-agnostic. The core has no concept of an editor, a document envelope, persistence versioning/migration, or UI. A
WidgetSystemRuntimeis the only thing an integration/renderer layer consumes; how it's drawn is entirely outside this package. - Dependency-mediated interaction. All cross-widget reads/writes go through dependencies declared in
registerDeps; there is no ambient way for one widget's callback to reach into another widget's state. - Reactive ownership.
alien-signalsowns dependency tracking, laziness, caching, invalidation, and batching; this package only builds observable semantics on top of it and does not implement a second reactive engine.
The canonical, authoritative decision log for this architecture is GitHub diagnostic #10 — "Widget composition core architecture — canonical decision log." Its consolidated implementation handoff comment, together with any later accepted amendment, is authoritative over this guide.