Imported from DevilTea/widget (
apps/lab/AGENTS.md). Install upstream withnpx skills add DevilTea/widget --skill lab. Copyright stays with the author.
AGENTS.md — widget-lab
Current-state operating guide for this app. GitHub issue #13 is the historical Widget Lab decision
log; issue #60 tracks the current post-WidgetDocument Lab redesign and accepted migration decisions;
issue #10 (with its detailed source/document design in #54) is the semantic authority for
@deviltea/widget-core that this app never reinterprets. This file describes what is
true in the repository right now so a fresh agent can work without replaying the issue timeline —
when it conflicts with repo reality, fix this file; current accepted decisions in #10/#54/#60 win over
stale prose here or historical #13 checkpoints that #60 explicitly supersedes.
Scope and layout
Private, never-published workspace application with two roles:
- Lab shell — the two-column workbench's
Authorworkspace (Catalog/Structure/JSON), Preview, and readonly Blueprint/Runtime/Dependencies inspectors over@deviltea/widget-core/@deviltea/widget-vue. - Released showcases — switchable via the header's showcase selector, all served by the same
shell and Document-backed authoring model:
Sandbox(minimal fixtures), Showcase AInteractive Survey, and Showcase BSales Pipeline CRM.
The app deploys to GitHub Pages together with docs/site (see "Deployment" below).
src/lab/— framework-agnostic, unit-tested authoring/runtime-host lifecycle logic:LabSession(JSON draft + authoritativeWidgetDocumentmutation + Runtime promotion,types.ts),author.ts(high-level Structure commands lowered toSourcePatch), and the shared cross-inspector focus store (focus.ts). No Vue import here on purpose — this is the regression-worthy core, independently testable.src/composables/use-lab-store.ts— the one place that bridgesLabSession/focus store into Vuecomputed()refs and supplies theLabSessionHooksseam (detachPreview/mountPreview) that guarantees unmount-before-dispose ordering vianextTick(). OwnsswitchShowcase()— the application-level whole-context replacement operation (teardown old Runtime → create target revision-0 Document/Runtime → mount it), serialized with Apply/preset/revert through oneenqueue()promise chain. Also ownsgraphShowAbsent/graphShowIsolatedMembers— Dependencies/Graph presentation preferences that deliberately live as plain refs on theLabStoreobject itself (not derived fromsession) so they survive Apply.src/composables/use-monaco-editor.ts+src/components/editor/MonacoJsonEditor.vue— the entiremodern-monacointegration surface. Nothing else in this app importsmodern-monaco.src/composables/use-runtime-member.ts— Vue bridge for one Runtime Inspector member view model (src/runtime-inspector/viewmodel.ts): adapts itsgetSnapshot()/subscribe()shape into a ref and disposes the previous view model whenever the reactive observable source changes (different selected member, or a Runtime replaced by Apply) or the component unmounts.src/composables/use-dependency-graph.ts— Vue bridge for the Dependencies panel's Graph view: projects the current Document Blueprint throughprojectSemanticGraph()and drives aLayoutSession(src/graph/layout-session.ts) that only requests a fresh ELK layout when the projected graph itself changes (new Blueprint snapshot or a graph filter toggle) — never on Runtime activity.src/composables/use-document-tools.ts— request-only bridge for the lazy, closable Phase 6 Document Tools developer panel; it owns no Document state and only asksWorkbench.vueto add or activate the panel, paralleling the Implementation explorer lifecycle.src/composables/use-graph-edge-selection.ts— panel-local Graph edge-selection state (edge selection never expands into the shared cross-inspector focus).src/runtime-inspector/viewmodel.ts— framework-agnostic passive view models (createStateMemberViewModel/createPropertyMemberViewModel) overRuntimeStateInspection/RuntimePropertyInspection. Calls onlygetSnapshot()/subscribe()— neverstate.get()/property.get()— so opening/subscribing an inspector can never activate a lazy Property.src/graph/— pure dependency projections plus the Graph layout pipeline; see "Dependencies inspector" below.src/implementation/— the curated Implementation source explorer's framework-agnostic core (issue #25 P3):types.ts(SourcesRegistry/CuratedSourceFile— metadata only,load()thunks are the lazy boundary),registry-coverage.ts(dangling/uncurated-type sanity used by unit tests),applied-instance.ts(pure extraction of a widget's own JSON fragment from the applied source text),focused-widget.ts(shared focus -> plain{ id, type }), andshiki-highlighter.ts(the lazy, self-contained Shiki fine-grained highlighter — see "Loading policy" below for its chunk and engine-choice rationale). Each showcase'ssources.ts(src/showcases/survey/sources.ts,src/showcases/crm/sources.ts,src/sandbox/sources.ts) is the actual curated registry, wired intoShowcaseEntry.sources(src/showcases/registry.ts) alongsidepresets.src/components/panels/ ImplementationPanel.vue(registered lazily — see below) andsrc/components/implementation/*(ImplementationFile.vue,ImplementationSourceView.vue) are the Vue layer;src/composables/ use-implementation-explorer.tsis the small, deliberately parallel open/activate bridge toWorkbench.vue's Dockview instance (see that file's header for why it is not an extension ofLabStore.activeTab).src/lib/issue-format.ts— shared structured-issue formatting helpers (formatIssuePath,formatDependencyReference, ...) reused byblueprint/IssueList.vueandruntime/RuntimePropertyIssueList.vue.messageis never parsed for structure; every field beyond it comes fromissue.source.src/components/— the app shell:LabHeader.vue(compact header: showcase selector, preset, status, Apply),Workbench.vue(dockview-vuetwo-column default layout),NonClosableTab.vue(a custom DockviewtabComponent— issue #27 Finding 2: registered astabComponents.nonClosableand selected viaAddPanelOptions.tabComponenton each of the five canonical panels'addPanel()calls, it renders only a title, no close control, so those panels can never be closed; Dockview's ownTabwrapper — drag/reorder/dock/resize/activate — is untouched, since atabComponentonly replaces what that wrapper renders as content),panels/*(Author/Blueprint/Runtime/Dependencies tabs, plus the lazily-registeredImplementationPanel.vue— issue #25 P3; it is the one panelWorkbench.vueregisters with notabComponentoverride, i.e. Dockview's own default (closable) tab, since it is deliberately not a sixth canonical non-closable surface),preview/PreviewPanel.vue, which consumes the private@deviltea/widget-devtoolsclient/agent boundary for Inspect mode. Phase B1 (#8) now runs that same-realm bridge over a real asynchronousMessageChannelrather than the A1 in-process pair; the Agent still owns bounded Preview DOM hit-testing, pointer suppression, highlight/badge chrome, and Escape cleanup, while the panel only translates scopedWidgetRefselection events into the existing Preview focus/navigation rules.src/preview-host/lifecycle.tsis the generation-aware remote-host state machine (booting/ready/replacing/disconnected/error) for the next iframe step. Preview Runtime ownership has not moved into an iframe yet; #8 owns Phase B implementation and #5 remains the iframe/extension umbrella,inspector/*(presentation-only inspector shell and tree/details split layout shared by Blueprint and Runtime; these components own no semantic data, revision labels, or focus state),blueprint/*(the Blueprint Inspector's tree + selected-node detail + issue list; the selected-node detail also carries a "View implementation" entry point),runtime/*(Runtime Inspector's member rows- property-issue list),
dependencies/*(the Dependencies view switcher + Relations UI),graph/*(the Vue Flow canvas + panel-local edge details).
- property-issue list),
src/App.vuealso renders a narrow-viewport gate (issue #27 Finding 3): a pure CSS@media (max-width: 899px)rule (no JS resize listener/state) shows aposition: fixedexplanatory overlay ("Widget Lab is designed for a desktop-sized viewport. Widen the window to continue.") covering the whole viewport below 900px width, and hides it again above that width;Workbench/Dockview stay mounted underneath rather than being torn down.src/sandbox/— theSandboxshowcase: small, Lab-private fixtures (plugins, aWidgetSystem, acreateWidgetVueRendererregistry, preset source texts) whose only job is to exercise the shell with minimal semantic surface. Product-shaped showcases live insrc/showcases/, never here; sandbox fixtures must stay small.src/showcases/— the released product showcases and their registry:registry.ts— the deliberately minimal{ id, label, system, renderer, presets }lookup table (no routing, no persistence) consumed byuse-lab-store.ts'sswitchShowcase. Plugin-tuple types are erased toWidgetSystem<AnyWidgetPluginTuple>on purpose — consumers only need object identity.survey/— Showcase A: Interactive Survey (trip-planning domain).domain.ts(pure domain helpers),plugins/(semantic plugins, unit-tested),renderers/(Vue renderer components),system.ts,presets.ts,test-support.ts.crm/— Showcase B: Sales Pipeline CRM. Same internal organization assurvey/(domain.ts,plugins/,renderers/,system.ts,presets.ts,test-support.ts), with a deliberately richer widget vocabulary (store/query/form/table/modal/metric plugins and renderers).- Business/semantic rules (scoring, staging, validation, derived read models) belong in each
showcase's
plugins//domain.ts, never in renderer glue — renderers present semantics, they do not compute them.
pika.config.ts— the Lab's small PikaCSS token set (variables.definitions, safe-listed so the hand-authoredsrc/styles/dockview-theme.csscan reference them via plainvar()).src/**/*.unit.test.ts— colocated Vitest unit tests forsrc/lab/,src/graph/,src/runtime-inspector/,src/sandbox/,src/composables/andsrc/showcases/**(plugins, presets, and selected renderer contracts via@vue/test-utils+happy-dom), run by both this app's ownvitest.config.tsand the root config.
Commands
pnpm dev # vite dev server
pnpm build # vite build
pnpm typecheck # generates pika.gen.ts, then vue-tsc over app, unit tests, and browser/Page specs
pnpm test # vitest run for this app's colocated *.unit.test.ts
vite.config.ts/vitest.config.ts/pika.config.ts and Playwright config files themselves are not
part of pnpm typecheck's program, matching every other package in this repo (none type-check their
own bundler config files). Browser/Page specs under e2e/ and e2e-pages/ are typechecked through
tsconfig.browser-tests.json, including their imported sandbox preset fixture, while the two
Playwright suites remain separate runtime jobs.
pnpm typecheck first runs pika:codegen (scripts/pika-codegen.mjs), which generates
src/pika.gen.ts directly through @pikacss/integration — the same file @pikacss/unplugin-pikacss's
Vite plugin would otherwise only produce as a side effect of an actual pnpm dev/pnpm build pass, and
which augments Vue's ComponentCustomProperties so pika() type-checks inside <template>. This keeps
pnpm typecheck correct on a clean checkout without requiring a prior dev/build run.
Deployment (GitHub Pages)
The Lab deploys as part of the docs Pages artifact: .github/workflows/docs.yml runs
pnpm docs:build:pages (scripts/build-pages.ts), which builds widget-lab and its workspace
dependencies in topological order with WIDGET_LAB_BASE=/widget/lab/ (consumed by
vite.config.ts's base), builds docs/site, then copies the Lab build under the docs dist's
lab/ subdirectory. docs/site and apps/lab remain separate source/application
boundaries — only build output is combined. If you touch the Lab's asset/worker URL behavior, verify
it still resolves under that subpath, not just under /.
modern-monaco's editor engine is self-hosted rather than loaded from its default esm.sh CDN — see
issue #30 Scope A. vite-plugin-vendor-modern-monaco-editor-core.ts (project root, alongside
vite.config.ts) reads editor-core.mjs plus its two Worker-chain siblings
(editor-worker-main.mjs, editor-worker.mjs) straight out of modern-monaco's installed
node_modules package — never committed to git — and (a) serves them from Vite dev middleware at
/vendor/modern-monaco/*.mjs, (b) emitFiles them into the production bundle at the same relative
path under dist/. Its transformIndexHtml hook injects a base-aware <script type="importmap">
mapping the bare specifier modern-monaco/editor-core to that URL, which modern-monaco's own
loadMonaco() reads and prefers over its esm.sh fallback — this is modern-monaco's documented "load
editor modules from a custom CDN" mechanism, just pointed at same-origin output instead of another
host. Because the plugin reads config.base at configResolved time, the importmap resolves
correctly under both plain / builds and the WIDGET_LAB_BASE subpath build above. See
use-monaco-editor.ts's ensureMonaco() for the other half (the removed defaultTheme option that
used to trigger a separate, unrelated esm.sh fetch for the theme JSON).
Loading policy
Intentional, not incidental (issue #30 Scope B) — the production artifact's chunk shape, verified by building this app in isolation:
main app JS (index-*.js) ~778 KB raw / ~214 KB gzip — eager
modern-monaco/core package JS (core-*.js) ~763 KB raw / ~275 KB gzip — loaded when Author JSON mounts
vendored editor-core.mjs ~7.93 MB raw / ~1.41 MB gzip — loaded when Author JSON mounts
vendored editor-worker(-main).mjs ~566 KB raw / ~119 KB gzip — loaded on first Monaco worker use
ELK layout worker (layout.worker-*.js) ~1.91 MB raw / ~465 KB gzip — loaded on first Graph layout
Implementation panel + Shiki (ImplementationPanel-*.js)
~373 KB raw / ~74 KB gzip — loaded on first Implementation open
curated raw-source chunks (per curated file, e.g. TableRenderer-*.js)
~0.6-14 KB raw / ~0.4-4.3 KB gzip each — loaded on first
selection of that file's tab
CSS (index-*.css) ~120 KB raw / ~11 KB gzip — eager
- Eager: the main app chunk and its CSS —
App.vue,Workbench.vue,LabStore/LabSession, every panel shell (not necessarily every panel's heavy dependency, see below), the Sandbox/Survey/ CRM showcases,dockview-vue. This is unavoidable critical-path weight for a workbench app whose shell has to exist before any panel can render. modern-monaco/core(package JS + vendorededitor-core.mjs): not in the eager chunk —import('modern-monaco/core')inuse-monaco-editor.ts'sensureMonaco()is Vite's own code-split boundary for the package JS (the LSP-free submodule — the side-effectful main entry that force-enableduseBuiltinLSPand carried ~740 KB of unused LSP/grammar code is deliberately not imported anywhere), andeditor-core.mjsis never in Vite's module graph at all (loaded bymodern-monacoitself via the importmap-resolved dynamicimport()described in "Deployment" above). Both are still fetched during initial load today, though:AuthorJsonView.vuerendersMonacoJsonEditorwhen the JSON view is active, Dockview mounts every one of the five canonical panels' Vue components as soon asWorkbench.vue'sonReady()callsaddPanel()for it (inactive: trueonly controls which tab is initially selected, not whether Dockview mounts the panel's content — verified: dockview-vue keeps every panel's rendered content in the DOM, not just the active one), and Author is the panel added first / made the active tab. SoMonacoJsonEditor'sonMountedruns immediately on app load regardless of which tab a user looks at. This is an accepted, documented trade-off, not an oversight: Author JSON is the Lab's default and most-used surface, so paying its cost immediately is preferable to adding a mount-gate (v-ifon tab activity) whose only benefit is deferring, never avoiding, that cost for the overwhelmingly common path where a user opens Author JSON anyway. If onboarding/information-architecture ever makes Author not the default active panel, revisit gatingMonacoJsonEditor's mount on the view actually being active rather than merely present.- ELK layout worker: same eager-on-load shape as Monaco, for the same underlying reason, and this
predates this issue's change — verified by loading the built app fresh (no tab interaction) and
observing
layout.worker-*.jsrequested immediately.layout-client.ts'sensureElk()still only creates theWorkeron the firstlayoutGraph()call rather than as an import-time side effect (so it is "lazy" in the narrow sense that comment describes), but that first call happens during initial mount today becauseDependenciesPanel.vue(like Blueprint/Runtime) is mounted immediately by Dockview per the same paragraph above, and itsuseDependencyGraph()composable'swatch(semanticGraph, ...)fires as soon as the app's initial default-preset Apply produces the first real Blueprint — before any user ever opens the Graph tab. Runtime state/property/method activity still never re-triggers a relayout (that guarantee is unaffected and unrelated to this paragraph); what does not currently hold is "ELK stays uninitialized until a user opens Graph." Fixing that would mean gating panel mount (not just tab selection) on Dockview activity for Blueprint/ Runtime/Graph, which is a Workbench/Dockview panel-lifecycle change out of scope for issue #30 — noted here as known, current-state behavior rather than silently left undocumented. - Neither of the two heavy, on-load fetches above is a regression: this document only makes explicit what was already true before this issue's change (Monaco and ELK both already loaded on initial mount before self-hosting), and self-hosting Monaco's engine does not move it into, or out of, that eager path — it only removes the esm.sh dependency for the fetch that already happened at that same moment.
- Implementation panel + Shiki + curated raw-source chunks (issue #25 P3): unlike Monaco/ELK above,
these genuinely stay lazy —
ImplementationPanel.vueis registered inWorkbench.vue'scomponentsmap throughdefineAsyncComponent(() => import('./panels/ImplementationPanel.vue')), and the panel itself is only ever added to Dockview (api.addPanel({ id: 'implementation', ... })) the first timeImplementationExplorerStore.open()is called — from Preview's "View implementation" button, Blueprint's selected-node detail, or the Survey tour's step 8 link — never atonReady()time the way the five canonical panels are. Verified by loading the built app fresh and driving each of those three entry points: no request forImplementationPanel-*.js(which statically bundlessrc/implementation/shiki-highlighter.ts—@shikijs/core,@shikijs/engine-javascript, and thetypescript/vue/jsontm-grammarsgrammars +tm-themes'one-dark-protheme) occurs before the firstopen()call, and one occurs immediately after. Within an open panel, each curated file's own raw text is a second, independent lazy boundary:showcases/*/sources.ts/sandbox/sources.tsonly holdsload()thunks (() => import('...?raw')), andImplementationFile.vueonly calls a file'sload()once that file's tab is actually selected — so opening the panel never fetches every curated file's text up front, only the initially-selected tab's, and switching tabs fetches the next one lazily too.e2e/implementation.spec.ts's lazy-boundary test pins both requests are absent before first open and present after (page.on('request') across a full page load, matching this section's own verification method) — mirroring the network-blocked fixture's own "assert what is/isn't fetched" posture (e2e/fixtures.ts) rather than adding a second, parallel assertion mechanism.
Package boundaries
This app is a private downstream consumer and architecture probe. It owns the Lab shell, inspectors, live source workflow, and the showcases. It must not own, and must not gain:
- any part of
@deviltea/widget-core's semantic compiler/runtime, or@deviltea/widget-vue's renderer/lifecycle/reactivity adapter surface — both are consumed exclusively through their public entry points (@deviltea/widget-core,@deviltea/widget-core/inspection,@deviltea/widget-vue); no private/internal import from either package; - persistence/versioning/migration of any kind —
LabSession'sdraftSourceText/documentStateandpreviewsnapshots are in-memory only, and Monaco's own workspace/IndexedDB machinery is deliberately not used (seeuse-monaco-editor.ts) so it can never become a second, competing source of truth; - an editor-command/undo architecture — Source text editing plus explicit Apply is the whole model.
When Lab work exposes a genuine gap in widget-core/widget-vue's public contract, canonicalize it on GitHub (#10/#13) instead of silently working around it in Lab-private code — implementation evidence may challenge architecture, but never rewrites it locally.
Inspectors are readonly
Blueprint/Runtime/Dependencies panels consume @deviltea/widget-core/inspection (inspectBlueprint,
inspectRuntime) as pure, passive projections. They must never:
- call
state.set(), invoke a Method, or otherwise force Property evaluation from inspector UI; - reconstruct semantic status from Issues, or run their own graph/SCC analysis — every fact comes from core's compiler/runtime-authoritative data;
- treat
issue.messageas a machine protocol — onlyissue.source.typeand structuredrelatedlocations drive navigation/rendering.
Real semantic interaction (State/Property/Method activity) stays exclusively in Preview
(src/components/preview/PreviewPanel.vue, via useWidget() from @deviltea/widget-vue). There is no
Observe action and no editor-domain operation anywhere in an inspector panel.
Runtime Inspector is strictly passive
src/components/panels/RuntimePanel.vue and src/components/runtime/* consume inspectRuntime()
readonly. State members render RuntimeStateInspection.getSnapshot()/subscribe() only — never
state.get(). Property members render Never evaluated until RuntimePropertyInspection's snapshot
reports status: 'completed' — the latest ExecutionResult some real Runtime consumer (Preview)
naturally produced — and never fresh/dirty/active/stale labels. Methods are inventory only (name
- the compiler's
transitivelyWritesfact); there is no invocation affordance. The Runtime Inspector always consumesstore.preview's Blueprint/Runtime and shows its Preview revision; an invalid current Document may leave an older valid Preview running, so it is not an unavailable Runtime state by itself. Only a session with no valid Preview has an unavailable Runtime panel.src/runtime-inspector/viewmodel.tsis the passive-projection layer this panel is built on — see its file-level comment and colocated tests for the exact contract.
Dependencies inspector
The canonical readonly Dependencies Dockview panel owns two presentation views over the same current Document Blueprint facts. Graph remains the default topology view; Relations is a focus-centric dependency inspector. Switching views is presentation-only: it preserves shared Document focus and never mutates semantic state.
src/graph/ implements the authoritative Lab-side dependency projections. The Graph view continues to use this pipeline:
BlueprintInspection -> projectSemanticGraph() -> toElkGraph() -> ELK layout -> toVueFlow()
relations.ts— pureprojectRelations():SemanticGraph + InspectorFocus + rootNodeId-> a framework-agnostic Relations view model. It imports neither Vue Flow nor ELK. Root/no usable focus yields an explicit empty state; member focus exposes exact incoming Used by and outgoing Depends on facts grouped by remote widget; widget focus treats all members as the selected set, keeps cross-widget incoming/outgoing separate, and lists same-widget dependencies in an internal section. Every row retains the original semantic edge/reference/path/operation; unresolved stubs remain unresolved and non-focusable. Relations sharesgraphShowAbsent, but deliberately projects with isolated members present so the Graph-onlygraphShowIsolatedMemberspresentation preference can never erase a focused member from this inspector. Resolved member/widget clicks write through the existing Document-scoped focus API; they do not request ELK layout.src/components/dependencies/DependenciesPanel.vueowns the internalRelations | Graphswitcher and view-specific toolbar. The switcher follows the WAI-ARIA tabs pattern (roving tab stop plus Arrow/Home/End keyboard navigation and labelled tabpanels). The Graph DOM stays mounted withv-show, preserving pan/zoom across view changes. Shared focus changes never mutate Graph expansion state or request layout/refit; cluster identity selection and the explicit expand/collapse toggle are separate actions.RelationsView.vuerenders the dense three-column inspector and the widget/internal-dependency mode without a canvas layout engine; same-widget unresolved stubs stay in the internal section rather than leaking into cross-widget outgoing dependencies.types.ts/projection.ts— the Lab's semantic graph shape and its pure, deterministic projection. Widgets are visual clusters (GraphCluster); State/Property/Method members are the semantic vertices (GraphVertex). Edge direction is owner -> declared dependency target;state-get/property-getproject asreads,method-invokeasinvokes,state-set(Method-only) aswrites.resolveddependencies becomeGraphEdges;absent/invalidbecome presentation-onlyGraphStubs, never a fabricated resolved edge.absentstubs (and any member with no other visible relation) are hidden unless the panel'sshowAbsent/showIsolatedMembersfilters are on;invalidstubs are always visible.transitivelyWritesandinvalidCyclesare projected verbatim from core inspection facts — this module never recomputes either.elk-adapter.ts— puretoElkGraph()/fromElkResult(), the JSON-shape boundary to ELK's graph schema. Accepts optionalLayoutGraphOptions(expandedClusterIds). Collapsed clusters are treated as leaf nodes with compact dimensions (COLLAPSED_CLUSTER_WIDTH,COLLAPSED_CLUSTER_HEIGHT), and cross-cluster edges between collapsed clusters connect to the cluster node directly with duplicate endpoints collapsed for layout efficiency. Unit-testable without a worker or the real elkjs runtime.layout.ts— the sharedLayoutedGraph/LayoutGraphFnadapter shapes andLayoutGraphOptions.layout-session.ts—createLayoutSession(layoutFn): framework-agnostic, generation-guarded async wrapper around anyLayoutGraphFn(real or a test fake). A layout result for a supersededrequest()call is discarded — this is what makes Graph layout safe as an asynchronous projection that never blocks Blueprint/Runtime/Preview availability (see "Layout worker boundary" below).vue-flow.ts—toVueFlow(): the laid-outSemanticGraph-> plain Vue Flownodes/edges. Supports hierarchical progressive disclosure:- Widget clusters are the top-level view by default (collapsed), preventing visual overload on dense topologies (e.g. Survey ~43 nodes / 77 edges).
- Expanding a cluster reveals its internal member vertices and stubs as child nodes.
- Inter-cluster edges between collapsed clusters aggregate into single presentation edges. The
canvas deliberately labels these with only a compact dependency count (
1 dep/N deps);semanticEdgesretains every exact member-level operation/path/reference for the details surface. - Selecting/focusing a widget or member highlights the connected subgraph and deemphasizes
unrelated nodes and edges (
isDimmed/graph-node--dimmed/graph-edge--dimmed). - Vue Flow is strictly viewer-only: every node is
draggable: false/connectable: falseand no edge isupdatable.
src/components/graph/GraphCanvas.vue— the only place in this app that imports@vue-flow/core(and its structuraldist/style.css); custom node templates forcluster,member, andstub, themed through Lab/PikaCSS tokens. Collapsed and expanded cluster identities and expanded member cards are keyboard-focusable/selectable without changing expansion state. The collapsed cluster card makes widget type primary, widget id secondary, and summarizes member inventory as S/P/M counts; the expanded cluster becomes a subdued compound inspector shell rather than a dashed debug boundary. Member nodes keep State/Property/Method distinguishable by a small letter badge plus restrained accent, so kind is not encoded by color alone. Edge operation is encoded primarily by stroke treatment (reads thin solid, writes stronger solid, invokes dashed), while exact non-aggregate labels are visually secondary until hover/selection. Handles remain intentionally unobtrusive and the canvas uses only a subtle dot grid: these cues must never imply that the readonly inspector is an editable node editor.src/components/graph/GraphEdgeDetails.vue— panel-local edge-selection details (dependency-containerpath+ reference target/operation, plus aggregated semantic dependencies list when an aggregated cluster-to-cluster edge is selected) — edge selection stays local, never expands into shared focus.
Graph works for an invalid current Document Blueprint (compile-time facts only, no Runtime dependency) and
its node click sets Document-scoped focus (nodeId + member). Blueprint and Graph never consume a
Preview-scoped node id when revisions diverge. Runtime and Preview use a separate Preview-scoped focus;
equal Document/Preview revisions synchronize the two scopes, while diverged revisions never map raw
InspectionNodeIds between them. A changed Document resets Document focus; a replaced Preview resets
Preview focus.
Viewport fit policy (issue #27 Finding 1 & reliable lifecycle):
GraphCanvas.vue coordinates fitView() with both layout readiness and container dimensions. In
Dockview, tabs mounted in inactive/background state have 0 client dimensions, which caused an initial
fitView() in onNodesInitialized to fail. GraphCanvas.vue pairs onNodesInitialized with a
ResizeObserver on the canvas container element to run attemptFit() as soon as non-zero dimensions
become available (e.g. when the user first switches to the Graph tab). Before accepting an automatic fit,
attemptFit() also verifies that Vue Flow's current internal node set exactly matches the current
presentation node ids and that every node has non-zero measured dimensions; onNodesChange retries while
a new layout generation is still being measured. This prevents fitView() from succeeding against only a
partially measured subset and then incorrectly marking that generation fitted. useDependencyGraph exposes
layoutVersion (incremented only when ELK layout succeeds), which resets fit tracking so the graph
automatically refits on semantic graph replacements and cluster expansion/collapse, while ordinary
Runtime activity and focus deemphasis never trigger a refit. DependenciesPanel.vue's Graph view also exposes an explicit
Fit graph button along with Expand all / Collapse all controls in the toolbar.
Layout worker boundary
src/graph/layout.worker.ts— the actual persistent Vite module Worker (new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' }), created lazily inlayout-client.tsand kept alive for the Lab's lifetime rather than a one-shot Blob Worker per request). Deliberately a single side-effect import —import 'elkjs/lib/elk-worker.js'— because elkjs itself splits into two halves:elk-api.js'sELKclass is the requesting-side orchestrator (owns aWorkerhandle, does promise/request-id bookkeeping), whileelk-worker.jsis the actual layout algorithm and self-registersself.onmessagethe instant it runs inside a Worker. This keeps the worker file itself thin (nothing worth unit-testing there — it is excluded from the test suite by design) and avoids theelk.bundled.jsbrowser bundle, which still spawns its own nested Worker internally and is not suitable for running a second layer deep inside a Worker we already own.src/graph/layout-client.ts— the main-thread side: wraps that persistent Worker with elkjs's ownELKclass (workerFactory: () => worker, reusing elkjs's protocol instead of hand-rolling a second one) behind thelayoutGraph(graph): Promise<LayoutedGraph>adapter, plusdisposeLayoutWorker()(called fromApp.vue'sonUnmounted— app lifecycle cleanup, unrelated to widget-core Runtime disposal, whichLabStore.dispose()already owns separately).- Only
src/composables/use-dependency-graph.ts's projected-graphwatchtriggers alayoutGraph()request (viaLayoutSession.request()) — Runtime state/property/method activity never touches the current Document Blueprint and therefore never relayouts. vite.config.ts'sworker: { format: 'es' }keeps this Worker's ownelkjsimport going through normal Vite ESM bundling.
Apply lifecycle
Editing the Author JSON view only ever mutates LabSession.draftSourceText; it never recompiles. Apply
parses the captured draft and submits one root SourcePatch replacement to core's authoritative
WidgetDocument. A structural changed:false accepts the Lab-local text representation without a
Document revision, Blueprint compile, Preview detach, or Runtime replacement. A changed:true commit
compiles/increments the Document revision first. If that committed Blueprint is invalid, authored state
advances but the Lab Runtime Host retains the exact last-valid Preview Runtime and its older revision;
there is no detach/dispose/mount. If the committed Blueprint is valid, the host then detaches/disposes
the previous Preview Runtime, creates a fresh Runtime for the new revision, and mounts it. Runtime state
is never migrated across valid revisions. LabSession.documentState and LabSession.preview are the
explicit two snapshots; the deprecated active compatibility shape must not be used to infer that its
current Document Blueprint and retained Runtime share a revision. See src/lab/session.ts and its tests
for parse-failure isolation, concurrent-Apply guard, no-op semantics, revision behavior, and ordering.
Do not add a second Lab path that calls system.createBlueprint() for authored-state mutation: manual
Apply and presets must go through the Document/SourcePatch boundary. Format/Revert remain draft-only.
switchShowcase() is intentionally different: it replaces the whole System/Document context and mounts
the target session's already-authoritative revision-0 Runtime directly; re-applying identical default
source merely to trigger mounting is forbidden because Core correctly treats it as changed:false. All
lifecycle-mutating operations remain serialized by the Lab store's transaction boundary.
Author workspace (Phase 3)
The canonical authored surface is the outer Author panel with three Lab-owned views: Catalog,
Structure, and JSON. AuthorJsonView.vue contains the existing Monaco draft workflow; header
Apply/Format/Revert controls continue to operate on that same draft. Catalog reads the immutable
public session.system.catalog and public WidgetPlugin.capabilities; it never reads or derives from the
curated ShowcaseEntry.sources Implementation Explorer registry. Core publishes config/slot/event
descriptions, capability-presence facts, and optional passive Draft 2020-12 config JSON Schema metadata.
The view must not invent state/property/method schemas or member definitions that Core does not publish
through the catalog contracts; config schema metadata is authoring guidance, not Runtime validation truth.
Structure renders the current committed Document Blueprint inspection and uses Document-scoped focus.
Its deliberately narrow operation is replacing an existing scalar config value on the selected inspection
node. ReplaceConfigScalarCommand carries both the current Document InspectionNodeId and Document
revision; src/lab/author.ts resolves that exact snapshot node and lowers the command to an array-form
SourcePatch. The component never constructs a patch and never targets a widget by id, which keeps
duplicate authored ids and cross-revision focus safe. Unsupported/non-scalar fields and mechanical patch
failures are explicit outcomes.
Successful Structure commands and JSON Apply share LabSession's Document commit and Runtime promotion
boundary. A successful Structure commit rewrites both applied source text and the JSON draft to deterministic
pretty JSON only when the draft still equals the command's captured clean text; a concurrent Monaco edit is
preserved as dirty. Invalid Document commits advance the authored revision while retaining the prior valid
Preview revision. No persistence, history, undo/redo, collaboration, drag/drop, or Runtime-state migration
belongs to this Phase 3 workspace.
Author recovery and diagnostics (Phase 4)
AuthorStatusSurface.vue is a Lab-owned status projection for the current Document. It reads the draft's Lab-only SourceParseError, Core's committed blueprint.sourceJsonCompatible and blueprint.status, Core's aggregate blueprint.diagnostics.length, Core inspection recovery counts, and the existing Document/Preview revision link status. It does not parse JSON outside Apply, perform a compatibility check, classify diagnostic codes, or turn a diagnostic message into a second protocol. JSON syntax errors therefore remain distinct from Core source-compatibility and Blueprint semantic status.
BlueprintTree continues to traverse BlueprintInspection.nodes and sourceSlots, so unresolved nodes and raw-slot placements remain visible and selectable. DiagnosticList displays Core's location/path fields and uses src/lab/diagnostics.ts only to resolve a node location through the current Document inspection. Source-level locations intentionally return no inspection node and remain non-navigable. Node-level diagnostic navigation writes Document-scoped shared focus; it never consumes Preview-scoped IDs or maps across diverged revisions. Structure and Blueprint read that same Document focus, while Runtime and Preview retain their Preview-revision scope.
Phase 4 adds no persistence/history/undo/redo/editor framework, second validator, compatibility implementation, or alternative authored source authority.
Separate inspector panels with shared presentation primitives (Phase 5)
Blueprint and Runtime remain separate outer, canonical Dockview panels. BlueprintPanel.vue is the
current Document inspection surface for semantic status, diagnostics, recovery nodes, and Document
focus; RuntimePanel.vue is the Preview-revision surface for passive Runtime state/property/method
inspection and Preview focus. They must not gain a shared Blueprint/Runtime mode, shared revision state,
or cross-revision InspectionNodeId mapping. Preview Inspect continues to select Blueprint when
Document/Preview revisions are linked and Runtime when they diverge; Graph and Implementation retain
their existing Document-scoped entry points.
src/components/inspector/InspectorPanelShell.vue and InspectorSplitLayout.vue are intentionally
small, props/slot-only presentation primitives. The shell owns only the repeated description-bar and
column-container chrome; the split layout owns only the tree/divider/details columns. Blueprint and
Runtime continue to provide their own data, status lines, diagnostics, recovery behavior, selection
handlers, and details components. Do not use these primitives as a semantic or focus abstraction, and
do not merge the two outer panels merely to remove presentation duplication.
Document Tools developer panel (Phase 6)
DocumentToolsPanel.vue is a lazy-added, closable developer surface in the left Dockview group, not a
canonical panel and not an Author subview. Its header entry point and use-document-tools.ts request
store parallel the Implementation explorer's lifecycle; Workbench.vue owns the actual Dockview
addPanel/activation operation and does not increase the canonical non-closable panel count.
The panel observes the latest successfully accepted SourcePatch from LabSession as transient
Lab-observed telemetry and offers copy-only display. JSON Apply and Structure commands remain the only
authoring paths. The optimistic-concurrency fixture calls the same public
WidgetDocument.applyPatch(patch, { expectedRevision }) contract with a stale revision and displays
Core's returned result; it has no Lab-owned conflict model and must leave both Document and Preview
revisions unchanged.
The separated-source section uses Core's public separateWidgetSource() only when the current Blueprint
is valid. It is a read-only projection of the current Document source, never an alternate source
authority or validator; invalid/recovery Documents show that the Core precondition is unavailable.
The Document trace is a finite, session-only ring of Lab-observed parse/commit/patch/conflict metadata. It is explicitly telemetry: no persistence, replay, restore, undo/redo, collaboration, or authoritative history semantics may be added. Runtime/Preview behavior, revision-scoped focus, Author recovery, Blueprint/Runtime separation, Dependencies, and Implementation entry points remain unchanged.
Active follow-up backlog
Post-convergence usability/hardening work is tracked on GitHub; read the issue before working in its area, and keep implementation truth in those threads rather than expanding this file:
- issue #25 — guided onboarding/tutorial + curated widget/component implementation source explorer;
- issue #26 — Survey semantic presentation correctness (result freshness, failed-Property display, dependency-issue propagation);
- issue #27 — Graph/workbench correctness and recovery;
- issue #28 — real-browser contract tests + baseline accessibility semantics;
- issue #29 — this document's current-state refresh;
- issue #30 — self-contained deployment / Monaco self-hosting / lazy-loading policy.
Testing
Intended split for this app:
framework-agnostic semantic/viewmodel logic -> Vitest unit tests
browser/workbench/DOM/focus/integration behavior -> narrow real-browser contracts
Unit tests are colocated *.unit.test.ts against real @deviltea/widget-core fixtures (no mocked
core): src/lab/, src/graph/, src/runtime-inspector/, src/implementation/, src/sandbox/,
src/composables/, and src/showcases/** (plugin semantics, preset validity, and selected renderer contracts via
@vue/test-utils + happy-dom). The real-browser contract harness is being introduced by issue #28;
until it lands, workbench/editor/browser-integration behavior has no automated coverage here — do not
compensate by writing broad DOM-simulation tests for it, and once the harness exists, put
browser-semantics assertions (focus, keyboard navigation, dialog behavior) there instead of in
happy-dom unit tests.
Root AGENTS.md's coverage policy lists the packages whose runtime source joins the root Vitest V8
coverage report; this app is not on that list and its sources are excluded from the coverage
include/exclude lists in the root vitest.config.ts; it still participates in pnpm test:unit via
test.include. Rationale: this is a private application shell, not a published package, and much of
its code is UI/editor/workbench chrome (Monaco, Dockview, template-heavy panels) whose value is
covered by browser contracts rather than the unit-coverage gate — folding it into the repo's
90%-threshold coverage gate would either force low-value DOM-simulation tests or silently lower the
bar for everything the gate already protects. The ELK layout Worker itself
(src/graph/layout.worker.ts) is deliberately excluded from unit testing — it is kept thin by design
(see "Layout worker boundary" above) and layout-session.ts's generation-guard tests exercise the
same contract against a fake LayoutGraphFn instead.
