@deviltea/widget-vue
@deviltea/widget-vue is a thin Vue 3 integration over @deviltea/widget-core Runtime semantics. It owns exactly three things — a keyed renderer registry, a recursive root renderer, and the useWidget(Plugin) bridge — and reimplements no core semantics of its own.
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. For an interactive playground built on this integration, see the Widget Lab.
Core model
createWidgetVueRenderer(system, build) -> WidgetRenderer component
build: renderers => renderers.TypeA(ComponentA).TypeB(ComponentB)...
<WidgetRenderer :runtime="runtime" />
-> recursive internal host tree, one host per rendered widget
useWidget(Plugin) (called inside a registered renderer component)
-> { useState, useProperties, useMethods,
useStateDiagnostics, usePropertyDiagnostics, useMethodDiagnostics,
useDiagnostics, WidgetSlot }
each accessor present only if Plugin declares that capabilityRenderer registry
createWidgetVueRenderer(system, build) uses the same keyed section-typestate model @deviltea/widget-core uses for state/properties/methods: the callback receives a keyed-chain builder whose Remaining type parameter starts as system's exact plugin-type union and shrinks by one literal per registered key. The callback can only type-check once every plugin type has been registered exactly once — there is no .register(type, component), .add(...), or mutable post-construction registry.
const WidgetRenderer = createWidgetVueRenderer(
system,
renderers =>
renderers
.Section(SectionRenderer)
.Text(TextRenderer)
.Input(InputRenderer),
)- Builder keys cover the same arbitrary string-literal domain as registered
plugin.typevalues, including non-identifier strings via bracket access (renderers['some-weird-type'](Component)) and collision-hazard names (__proto__,constructor). - A system whose plugin-type union has been broadened to plain
stringcan never produce a complete registry:Exclude<string, AnyLiteral>staysstring, so the callback's return type can never satisfy the requiredRemaining extends nevercompletion. - Construction also validates coverage against the actual
systeminstance at runtime (type-level completeness alone cannot stopany/untyped call sites): missing, unknown, or duplicate renderer registration all throwWidgetVueIntegrationError— a programmer/configuration exception, never a Widget Diagnostic. - The returned component is bound to the exact
WidgetSysteminstance supplied; aWidgetSystemRuntimefrom a different (even structurally identical)WidgetSystemis rejected the same way.
Root renderer contract
<WidgetRenderer :runtime="runtime" />runtime: WidgetSystemRuntime<Plugins>is the only semantic prop. There is no raw source, Blueprint, root-widget, fallback-renderer, or loading/error-slot prop.- Mount/render time validates
runtime.blueprint.system === boundSystem. - If the
runtimeprop's identity changes, the entire internal host/renderer tree fully unmounts and a fresh tree mounts — even when the rootid/typeare identical — so every Vue bridge subscription from the previous Runtime is guaranteed to be cleaned up. WidgetRenderernever callsruntime.dispose(), on unmount or replacement. Runtime lifetime is owned by the caller.
Internally, the package owns a private recursive host tree: WidgetRenderer renders an internal host per widget, which provides the current RuntimeWidget through a private Vue injection context and renders the registered component for that widget's exact plugin type. Renderer components never receive a widget prop — they call useWidget(Plugin), and Plugin doubles as the compile-time type witness and a runtime exact-identity assertion (a mismatch throws WidgetVueIntegrationError).
useWidget(Plugin)
const {
useState,
useProperties,
useMethods,
useStateDiagnostics,
usePropertyDiagnostics,
useMethodDiagnostics,
useDiagnostics,
WidgetSlot,
} = useWidget(SectionPlugin)Every accessor is derived from Plugin's declared WidgetInterfaces: an absent capability drops the corresponding accessor entirely; an explicitly-declared-empty capability (for example state: Record<never, never> or the canonical explicit-empty slots spelling slots: never) keeps the accessor present with an empty (or, for WidgetSlot, never-typed) keyed surface. Presence is read from plugin.capabilities at runtime and from HasWidgetCapability<Interfaces, Key> at the type level — both declaration-presence facts, never a [Payload] extends [never] test, since a legitimately-present capability can itself have payload never. useDiagnostics() and WidgetSlot's gating aside, this mirrors exactly how @deviltea/widget-core's own RuntimeStateSurface/RuntimePropertySurface/ RuntimeMethodSurface gate state/properties/methods on the Runtime widget itself.
Every useXxx() call returns a typed keyed Proxy-like surface. Obtaining the surface, and accessing a member off it, never reads or subscribes the Runtime — only materializes/caches that member's Vue wrapper. Both the capability-surface object and each member wrapper are stable/cached within one useWidget(Plugin) call.
State
const { budget } = useState()
budget.value // T | null
budget.value = 100_000
budget.value = null- Backed by a
customRef-style projection with no authoritative mirrored cache. The first.valueread activates exactly one Runtime subscription; the Runtime subscription's listener onlytrigger()s Vue — the next getter always re-reads the Runtime directly. - The setter delegates to
RuntimeState.set()and performs no optimistic local write.nullis a legal Vue-boundary candidate; the Runtime's ownvalidateremains authoritative and may reject it. - If a candidate is rejected, the bridge explicitly invalidates Vue so consumers (in particular
v-model) re-read the authoritative Runtime value instead of keeping the rejected candidate.
Properties
const { estimatedCost } = useProperties()
estimatedCost.value // T | null, readonlyAn ExecutionResult with ok: true projects its value; the ok: false branch projects to null, with no last-successful fallback. The ExecutionResult itself is never exposed through this surface.
Methods
const { submit } = useMethods()
submit(...args) // ReturnType<Fn> | nulluseMethods() exposes lazy, stable callable wrappers shaped (...args: Parameters<Fn>) => ReturnType<Fn> | null — not refs, and no subscription is created merely by obtaining or calling one. Semantic ok projects to the returned value; semantic failure projects to null. Implementation-contract exceptions and disposed-Runtime errors propagate unchanged. Arbitrary legal method names, including JavaScript-special ones such as then, receive no special handling.
Diagnostics
const { budget: budgetDiagnostics } = useStateDiagnostics()
const { estimatedCost: estimatedCostDiagnostics } = usePropertyDiagnostics()
const { submit: submitDiagnostics } = useMethodDiagnostics()
const diagnostics = useDiagnostics()useStateDiagnostics() / usePropertyDiagnostics() / useMethodDiagnostics() mirror the corresponding Runtime primitive's getDiagnostics()/subscribeDiagnostics() through the same lazy keyed-Proxy model as their value counterpart, independently lazy per member. useDiagnostics() mirrors the widget-level aggregate — RuntimeWidget.getDiagnostics()/subscribeDiagnostics() — never the Runtime-wide WidgetSystemRuntime.getDiagnostics(). Every projection preserves the core snapshot objects and order exactly: no message parsing, no reclassification, no invented aggregation.
Slots
<WidgetSlot name="content" />Present on the useWidget() surface only when the plugin declares a slots capability, typed to its exact declared slot-name union. WidgetSlot is syntactic sugar over one shared internal component identity — obtaining it repeatedly, across widgets or useWidget() calls, never allocates a new component definition. It renders the current widget's semantic slot children recursively, in declaration order, with no filtering, sorting, or fallback-renderer behavior of its own.
What useWidget() deliberately does not expose
No runtime, system, or unrestricted getWidget escape hatch. Every cross-widget interaction stays mediated by @deviltea/widget-core dependencies (registerDeps); renderer code cannot reach across the widget tree outside that mechanism.
Design constraints
- No domain widgets. Survey/Product Prototype (or any other) domain widgets stay out of this package.
- No editor-domain UI. Lab inspectors, source editors, and similar editor-domain operations stay out of this package.
- No persistence. Persistence, versioning, and migration stay out of this package.
- No showcase state. Showcase-specific application state or business rules stay out of this package; they belong to the downstream consumer (
widget-lab). - Core stays authoritative. This package never reimplements or reinterprets
@deviltea/widget-coresemantics; it only projects the existing Runtime surface into Vue-native reactivity.
The canonical, authoritative decision log for this integration is GitHub diagnostic #13 — "Widget Vue integration — Phase 3 decision log." Its checkpoints, together with diagnostic #10 for core semantics, are authoritative over this guide.