The anywidget front-end module (AFM) that hosts @simlin/diagram's Editor inside a notebook cell for pysimlin's ModelWidget (JupyterLab 4, Notebook 7, VS Code, Colab).
It builds to ONE self-contained ES module, dist/widget.js, which pysimlin ships inside its wheel as the widget's _esm; the engine wasm (src/engine/core/libsimlin-browser.wasm) ships beside it in the wheel and reaches the browser over the widget comm at runtime.
Design: docs/design-plans/2026-08-17-pysimlin-widget.md.
For global development standards, see the root CLAUDE.md. For the TypeScript conventions, see docs/dev/typescript.md.
anywidget loads _esm by writing the module text into a blob: URL and import()ing it (anywidget load.ts), once per widget instance (runtime.ts builds a Runtime per model, each with a fresh blob URL).
Three consequences drive this package:
- No relative assets.
import.meta.urlis a blob URL, so nothing next to the module can be fetched: no worker chunk, no.wasm, no.css, no font files. The bundle injects its CSS from JS (output.injectStyles), inlines fonts as data URIs, and receives the wasm as bytes over the comm. Anything that would emit a second output file is a bug in this package's build; rspack's ESM runtime also defines its base URI by resolving./againstimport.meta.url, which THROWS for blob URLs at module init --tools.cssLoader.esModule: falsekeepsurl()handling out of that runtime (see rsbuild.config.ts). - The page is someone else's. The CSS lands in the notebook's
<head>(no shadow root), so every global stylesheet the Editor needs --theme.css(design tokens on:root, a universal reduced-motion rule) and katex (body { counter-reset }) -- is rewritten at build time to live under the widget root class.simlin-notebook-widget(build/scope-css.ts, a PostCSS plugin wired throughtools.postcss; CSS Modules are skipped, their classes are hashed already).build/scope-css.test.tsruns the plugin over the real theme.css and katex.min.css and pins that no top-level selector survives without the root class, and the Playwright journey checks the page root sees none of the tokens. N displayed widgets still inject N (identical, scoped) copies of the styles -- harmless, and unavoidable while each instance is its own module. - Module state is per widget instance. Every displayed widget gets its own copy of this module, its own React, and its own
@simlin/enginewasm singleton. The only page-wide sharing possible is throughglobalThis, soengine-bootstrap.tscaches the COMPILEDWebAssembly.Module(the expensive part) underglobalThis["__simlinWidgetWasmModule:<sha256>"]-- keyed by the sha256 of thelibsimlin-browser.wasmthe bundle was built against, baked in byrsbuild.config.ts(source.defineSIMLIN_WIDGET_WASM_SHA256; the build fails if the wasm is missing) -- and each instance instantiates its own engine from it viaready(module). Two cells displaying models compile the wasm once and ask the kernel for it once; two bundles from different builds on one page (two kernels with different pysimlin versions, an old output beside a redisplay after an upgrade) each compile their own instead of the second running the first one's wasm against a JS side built for another ABI. The key states what the bundle was BUILT with, not what the kernel sent: a wheel whose widget.js and wasm come from different builds is a packaging error the staging/wheel checks own (ASSETS.jsonrecords both hashes). initializemust return fast. anywidget rejects a model whose module load plusinitializeexceed ~2 s (runtime.ts,AbortSignal.timeout(2000)), after which every comm message for that model is dropped.initializeonly kicks off the engine bootstrap;renderawaits it. anywidget also queues comm messages until the module has loaded (_handle_comm_msgawaitsruntime.ready), so an early kernel reply is safe.
- Wasm flavor:
@simlin/engine/internal/wasmis aliased to@simlin/engine/internal/wasm.supplied-- no bundled artifact, no asset fetch;init()without a source throws. The bootstrap handsready()the shared compiled module. - Backend:
@simlin/engine/internal/backend-factoryis aliased to@simlin/engine/backend-factory.direct-- libsimlin runs on the main thread. Chosen over the Web Worker backend because a worker needs a second file (or an inline-blob worker built in a second compilation stage plus a new engine factory that spawns from source), and the widget's simulations are the Editor's sparklines and small models -- heavy runs happen in the kernel. Cost: opening/compiling a very large model blocks the notebook UI for the duration (seconds for a C-LEARN-sized model). The measured worker chunk is small (SIMLIN_WIDGET_BACKEND=worker pnpm build: ~38 KB raw / ~10 KB gz, self-contained), so an inline-blob worker is a contained follow-up if main-thread jank becomes a problem -- it is not a hack, just a second build stage; the as-built worker variant is NOT loadable from a blob URL (its chunk URL is resolved againstimport.meta.url). - Inline wasm (
SIMLIN_WIDGET_ASSET=inline, pysimlin_widget_core.inline_esm): the kernel prependsglobalThis.__simlinWidgetInlineWasm = "<base64 wasm>";to the module text, so the artifact is on the page when the module evaluates.engine-bootstrap.tsreads the global ONCE at module evaluation (takeInlineWasm: the same synchronous script that set it, so a later instance from another build overwriting the global cannot hand this instance the wrong bytes) and clears it, decodes and compiles it into the same page-wide cache slot a comm-delivered module would fill, and never sends{type:'wasm'}; an unusable payload (bad base64, not wasm) falls back to the comm request with a console warning, since the kernel answers wasm requests in every asset mode. - Wasm handshake (
engine-bootstrap.ts), thebundled/urlmodes: the first instance on the pagemodel.send({type:'wasm'}); the kernel answers with a custom message{type:'wasm'}whose first binary buffer is the artifact (model.on('msg:custom', (msg, buffers) => ...), buffers arrive asDataViews from ipywidgets), or{type:'wasm', error}when it cannot. A reply without a buffer is an error, not a hang; no reply within 60 s is an error whose text (wasmTimeoutMessage) names the case a user actually hits -- a static export (nbconvert --executewith the defaultstore_widget_state=Trueexports the widget, whose request no kernel answers) and its fix (--ExecutePreprocessor.store_widget_state=False, which exports the SVG) -- because the widget cannot detect a missing kernel up front (the anywidget model proxy exposes no comm liveness andsendon a comm-less model is a silent no-op); a failed shared promise is dropped so a later widget retries.
Trait names live in widget-core.ts TRAITS.
Kernel-owned traits: project_json (the authoritative whole-project engine-native JSON snapshot; THE WIDGET NEVER SETS IT), revision (integer, kernel-only), height, theme (auto|light|dark), read_only, max_snapshot_bytes (the largest snapshot the widget may send, measured as it rides in the message -- the snapshot text JSON-string-escaped, in UTF-8 bytes; default MAX_SNAPSHOT_BYTES = 8 MiB, the same constant in pysimlin's _widget_core.py).
Widget-written trait: selection.
Custom messages, widget -> kernel: {type:'wasm'}, {type:'snapshot', id:string, base:int, json:string} (id is the request id, unique per view and per request: requestId(viewToken, seq), the token minted once per mounted view), {type:'oversize', bytes:int} (sent INSTEAD of a snapshot whose wire size exceeds max_snapshot_bytes; bytes is that wire size; owed no reply, carries no id).
Kernel -> widget: {type:'wasm'} + binary buffer / {type:'wasm', error}, {type:'saved', revision:int, id}, {type:'rejected', revision:int, id} (id echoed verbatim from the snapshot being answered), {type:'notice', text, level?:'info'|'warn'}.
Why snapshots ride in a custom message and not a trait: ipywidgets allows ONE in-flight trait sync per model and assign-MERGES the patch messages it buffers behind it, so consecutive trait writes reach the kernel collapsed (a burst {S1,base n},{S2,base n+1},{S3,base n+2} arrives as {S1,base n},{S3,base n+2}: S2 lost, S3 rejected).
Custom messages are queued in order and never merged, and an explicit reply per snapshot removes any need to infer acceptance from trait echoes.
- Seed: set
project_jsonandrevisionbefore the widget is displayed. The Editor mounts the root model's first view, so a model with no view SHOULD be laid out first (pysimlinProject._ensure_view, a committed change likeauto_layout()); a seed that arrives viewless anyway is repaired with an empty view on the widget side (withEditorView). - Exactly one reply per
snapshot, echoing itsid. Wrap the whole handler so that ANY exception (parse failure, write error, a bug, even aKeyboardInterruptthat is then re-raised) ends in exactly one reply --{type:'rejected', revision, id}when nothing was applied,{type:'saved', revision, id}when the change was applied before the failure (obligations 4 and 5), and no second reply when the failure came after the reply already went out (a followingnoticefailing to send) -- plus anoticecarrying the error. The reply'sidis the snapshot'sidexactly as received (any JSON value; omitted only when the snapshot carried none): the widget resolves a save ONLY on the reply whoseidmatches the snapshot it has in flight and ignores every other reply, so a reply without the id is a reply that never arrives -- the save hangs, andProjectControllerruns one save at a time, so every later save of that view hangs with it until it is remounted. A malformed snapshot (badbase/json) that carries anidis still rejected WITH that id.revisionin a reply is a JSON integer. - Accept a
snapshotwhosebaseequals the current revision: apply and write the file, then inside ONEhold_sync()assignproject_json = <the exact bytes received>andrevision += 1(plain assignments -- the frontend never set this trait, so there is no property lock and nosend_stateis needed), then send{type:'saved', revision, id}. The state message SHOULD leave beforesaved(exit thehold_sync()block first): in JupyterLab (ipywidgets 8.1 / jupyterlab 4.3) a state update is applied on the awaited promise chain before a following custom message dispatches, so with that order the widget sees push thensaved; the widget tolerates the reverse (it adopts the accepted state fromsaveditself), so this is a SHOULD, not a MUST, but keeping the order avoids relying on that tolerance in hosts (VS Code, Colab bridges) whose dispatch order is unverified. - Reject a
snapshotwhosebaseis stale, or that could not be applied at all (it does not parse; the kernel raised before applying): never write the file from it; do NOT touch the traits (they already hold the authoritative state, and only the kernel writes them); send{type:'rejected', revision, id}, optionally with anotice. A kernel that raised AFTER applying (a bug between apply and reply) must NOT replyrejected: that is obligation 5's situation -- the change is real -- and arejectedthere costs the view a remount (undo history and any local edits since are lost) for a change that is real. Reply as in obligation 5: push the exact bytes received atrevision + 1(best effort) and send{type:'saved', revision, id}plus a warnnotice. - Applied but unwritten (the snapshot was applied in memory but the file write failed -- disk full, permissions): this is an ACCEPT, not a reject.
The kernel's revision has advanced by one and the project is dirty, so it pushes the exact bytes received at
revision + 1and sends{type:'saved', revision, id}exactly as in obligation 3, plus a{type:'notice', level:'warn'}naming the error and the fix (model.save()). Replyingrejectedhere would leave the widget's acknowledged base one behind the kernel's and every later save would be rejected as stale. - Own changes (Python
edit(), a disk reload): assign both traits in onehold_sync(); add a{type:'notice', text:'Updated on disk'}for disk. - Notices are custom messages, not a trait: a notice is an event (a second identical "Updated on disk" must show again), which a trait cannot express.
- Wasm: answer
{type:'wasm'}with a custom message{type:'wasm'}whose first binary buffer islibsimlin-browser.wasm, or{type:'wasm', error: string}-- in every asset mode, includinginline(the widget falls back to the request when the inline payload is unusable). - Only the kernel ever writes
project_jsonandrevision. - Size: seed
max_snapshot_bytes(default 8 MiB) below the notebook server's browser->kernel websocket limit (tornadowebsocket_max_message_size, 10 MiB by default, for any host that reaches the kernel through a Jupyter server) with room for the envelope and message header, which are a few hundred bytes -- the cap is measured on the snapshot AS IT RIDES IN THE MESSAGE (JSON-string-escaped UTF-8; pysimlinsnapshot_wire_size, JSsnapshotWireSize), so no other content-dependent inflation remains. Answer an{type:'oversize', bytes}report with a warnnoticeand NOTHING else -- nosaved/rejected: the widget resolved that save itself and has nothing in flight. pysimlin also emits aRuntimeWarning, which goes to the kernel's stderr (a comm handler runs outside any cell, so JupyterLab shows it in the Log Console, not a cell output -- the toast is what the user sees). Warn the Python user (once per widget) when seeding or pushing aproject_jsonabove the cap, since every editor edit of that model will be refused; the warning is attributed to the user's cell (pysimlinuser_stacklevel(), which skips pysimlin's own frames and IPython's display-formatter frames -- attributed toformatters.pyit would be one source location for every display and Python's once-per-location filter would show it once per kernel session).
- At most ONE snapshot in flight per view, and replies are matched by
id.onSavesends{type:'snapshot', id, base, json}and returns a promise that resolves ONLY when the reply echoing thatidarrives (replyIsFor):saved-> the new revision (the controller adopts it as acknowledged) and the widget adopts (revision, sent bytes) as the Editor's seed right then;rejected->undefined(the controller keeps its version). A reply naming some other request -- or none -- is ignored, whatever is in flight: another view's reply (every custom message reaches every view of one model), the reply owed to a snapshot whose slot a remount freed, a reply after unmount. A reply-typed message with a missing/non-integer revision but the matchingidis treated as a reject, not ignored (it still consumes the one reply the snapshot is owed; ignoring it would hang the save queue).ProjectControlleralready serialises saves (one in flight, one queued flush that re-serialises the LATEST state and re-reads the acknowledged version), so with a busy kernel (a long-running cell) the Editor keeps working locally and one flush of the latest state goes out when the answer arrives. There is deliberately no timeout while in flight -- a long cell legitimately delays the reply and a timeout would misfire -- but unmount/abort resolves itundefinedso nothing dangles, a reply arriving with nothing in flight is ignored, and anonSaveafter unmount (a queued flush racing the dispose) is refused withundefinedrather than sent. A secondonSavewhile one is in flight (the controller never does this) is likewise refused. - Kernel trait pushes (
change:project_json/change:revision; the two traits travel in onehold_syncbut surface as up to two events, so the handler classifies the FINAL pair idempotently,classifyPush): if a snapshot is in flight and the pair equals (in-flight json, base + 1) it isown-ack-- keep the live Editor and its undo history, but the seed is adopted only by the matchingsavedreply, because the pair proves the kernel holds our bytes at base + 1, not that OUR snapshot was the one accepted: another view of the same model can send byte-identical bytes against the same base and win, after which our reply isrejected, and had the pair been adopted as the seed that reject would find nothing to remount and leave the Editor acknowledged atbasewith every later save stale (a repeat of the same push still classifiesown-ack, so nothing remounts meanwhile); if it equals the seed pair (the pair asavedreply adopted), nothing; otherwise remount<Editor key=revision#generation>on it (kernel edit, disk change -- including one that raced an in-flight snapshot). Push-then-savedandsaved-then-push therefore both end with one live Editor and no remount. A remount while a snapshot is in flight resolves that saveundefinedat once (its Editor is gone; nothing may wait on a reply for a mount that no longer exists) and FREES the slot, so the new Editor's first save goes out with the new base instead of being refused as "one already in flight" (which left that edit unsaved until a later edit); the reply the kernel still owes for the freed snapshot carries that snapshot'sid, matches nothing in flight, and is ignored -- it neither remounts (the push already did) nor adopts its pair as the seed (a latesavedfor the old snapshot would step the seed back behind the state just remounted on). Matching by id needs no count of replies still owed and no assumption about reply order or which view a reply was meant for. - Reject handling is defense in depth under either order: when the kernel's state moved to something OTHER than the sent bytes (a foreign change raced the snapshot), its trait push already remounted the Editor and the following
rejectedre-checks the (revision, json) pair and finds it already seeded (no second remount); when it did not move (a reject at an unchanged revision: the kernel raised before applying anything), the pair equals the seed and nothing remounts -- the Editor keeps its local edits at the same version and the next save goes out against the same base. A kernel that applied the snapshot and then saidrejectedis handled (the push classifiesown-ack, the seed is not adopted, so the reject remounts on the kernel's pair atbase + 1) but not for free: arejectedthere costs the view a remount (undo history and any local edits since are lost) for a change that is real -- which is why the kernel side treats apply-then-fail as an accept (obligation 4). Remounts are idempotent on the pair (remountFrom);generationmakes the key change even when the revision number does not. - Never sends a snapshot above
max_snapshot_bytes(checkSnapshotSizeon the wire size --JSON.stringifythen UTF-8 bytes viaTextEncoder, mirroring pysimlin'ssnapshot_wire_size-- read live from the trait so a raised cap is honoured without a remount): the server would drop it (tornado closes the socket with 1009 on an oversize frame; JupyterLab reconnects the kernel silently) and the save would never resolve. InsteadonSavesends{type:'oversize', bytes}, shows its own warn toast ("Edit not saved: the model is too large for the notebook connection (N > M limit); edit it from Python instead." with sizes fromformatSize, KiB below 1 MiB else MiB to one decimal, round-half-to-even -- byte-identical to pysimlin'sformat_sizeon the fixture list pinned in both suites, so the kernel's notice and the toast collapse into one), and resolvesundefined: the controller keeps its version and the Editor its local edit, and the next edit is refused the same way until the model shrinks or is edited from Python. Exactly at the cap is sent. - The widget never writes
project_json; onlyselection(set +save_changes()at most every 150 ms). A kernel-originated remount publishes an EMPTY selection through the same debounce (superseding any pending one): the new Editor starts with nothing selected and, like every Editor, does not report its initial selection, so without thisselection/Model.selectionwould keep naming what the replaced Editor had selected -- possibly variables the push removed. - The user's viewport survives a kernel-originated remount. A pan or zoom is never saved by itself (
ProjectController.setViewportpersists it to the engine through a viewport item that does not save; only the next edit's snapshot carries the viewBox/zoom), so a remount on the kernel's bytes -- a Pythonedit(), a disk reload, a reject re-seed -- would reset the framing to the stored one, and a project whose stored viewBox is still the unset 0/0/0/0 (a converted model never edited in the browser) would re-centre on the grown content every time the kernel adds a variable.WidgetAppkeeps the live Editor's last committed viewport (onViewportChange, once per settled gesture/fit, never per frame) andremountFromhands it to the new mount asinitialViewportwhenviewportToCarry(widget-core) says the kernel change did not itself move the viewport: the incoming project's stored viewBox/zoom equal the OUTGOING seed's (the text the live Editor was mounted from) or the incoming one is unset; a kernel that moved the stored viewport wins (nothing carried), and a live viewport of a drilled-into module is never carried onto the root the remount opens. The Editor applies it before its first render, round-trips it view-only, and skips its mount-time offscreen re-center for it, so nothing moves and nothing is saved by the carry. Inherent to using the stored viewBox as the signal: with two views of one model (see the known limitation below), an edit saved from one view persists THAT view's viewport, the other view sees a kernel push whose stored viewport moved, and it adopts it -- both views converge onto the editing view's framing.src/pysimlin/e2e/notebook-editor.spec.tsasserts it in a real JupyterLab (shift-pan, Pythonedit(), the stock is where the pan left it to the pixel; the laid-out-on-display model does not shift). - Notices auto-hide after 5 s; a repeat restarts the timer.
The toast sits bottom-centre at 80px, above the Editor's own snackbar viewport (
Snackbar.module.css, bottom 8px) and above the tool dial FAB (top edge at 72px, so a wrapped two-line notice in a narrow cell still clears it), away from the search bar -- the top edge is Editor chrome ("Find in Model", the details drawer) and a toast there covers it.theme: 'auto'follows JupyterLab'sbody[data-jp-theme-light](aMutationObserver) elseprefers-color-scheme(amatchMedialistener), live, both unsubscribed on unmount.
Correctness boundary: the file is only ever written from a snapshot whose base equalled the kernel's revision at handling time, so a snapshot edited from stale state can never overwrite a change the widget has not seen; what a reject costs is the local edits since the last accept, which the remount discards (the notice tells the user).
Two mechanisms, both needed, both only observable in a real JupyterLab (src/pysimlin/e2e/notebook-editor.spec.ts, the AC2.6 test):
- Focus must enter the widget on any press. The Editor's canvas
preventDefault()s every pointerdown, so a click on a variable moves no focus by itself; the Editor's root focuses itself on a capture-phase pointerdown whenever focus is outside the editor (src/diagram/CLAUDE.md, keyboard scoping contract). Without it focus stayed on the notebook cell and Lab's command-mode keys acted on CELLS (xcut,d ddeleted) while Delete reached no Editor. - The attribute must be on the focused element. Lumino resolves a keydown by walking up from the focused element and, at each step, first stops on
data-lm-suppress-shortcutsand otherwise matches the element against every binding selector; the notebook's command-mode bindings (selector.jp-Notebook.jp-mod-commandMode:not(.jp-mod-readWrite) :focusin JupyterLab 4.6's notebook-extensiontracker.jsonschema; text fields are exempt because the Notebook togglesjp-mod-readWritewhile an editable element is active, not through the selector) match the focused element itself -- the Editor root, the canvas container, a button, a<select>-- before the walk reaches the wrapper. So the wrapper's attribute alone is not enough:WidgetAppstamps the attribute onto the target of every focus event inside the widget (stampShortcutSuppression,onFocusCaptureon the wrapper; the overlay surfaces portal into the wrapper, so they are covered too). Do NOT try to stop key propagation at the wrapper instead: Lumino listens on the document in the capture phase (before any element handler) and the Editor's own shortcut listener is a document-level bubble handler that a stop would silence.
anywidget runs render once per VIEW; two views of the same model (display(w) twice, or a JupyterLab "create new view for output") each mount their own Editor and their own in-flight slot against ONE model.
Their saves interleave on the same comm: view A's accept is a foreign push for view B (it remounts, losing B's undo history and unsaved local edits), and every reply's msg:custom reaches both views -- each resolves only the reply echoing its own request id (index.test.tsx pins both the distinct-bytes and the byte-identical two-view interleavings).
Correctness holds -- every write is a kernel-accepted snapshot and no view ever takes another's reply as its own -- but two live editors of one model on one page is not a supported editing arrangement; display two ModelWidget instances (two cells) instead, which each carry their own model.
pnpm build here is rsbuild build && pnpm run stage; stage runs src/pysimlin/scripts/stage_widget_assets.py --no-build, the ONE owner of copying dist/widget.js and src/engine/core/libsimlin-browser.wasm into src/pysimlin/simlin/_widget/ (git-ignored package data) and writing ASSETS.json beside them (source commit, wasm-opt mode from build.sh's .mode stamp, per-file size and sha256; deterministic, so an unchanged tree restages nothing).
Do not add a second copy step -- pysimlin's make assets, src/pysimlin/scripts/build_wheels.py, and the release workflow all call that script (which by default runs pnpm --filter "@simlin/notebook-widget..." run build first). pysimlin's setup.py refuses to build a wheel/sdist when the assets are missing, empty or do not match the manifest, and release.yml builds them once (ubuntu, --require-opt), ships that one set in every platform wheel, and checks each wheel with src/pysimlin/scripts/check_wheel_assets.py.
The pre-commit hook builds with DISABLE_WASM_OPT=1, so a locally staged wasm is usually raw; that is fine for development and refused for release.
src/index.tsx-- AFM entry,export default { initialize, render }. Imports@simlin/diagram/theme.cssand KaTeX'skatex.min.cssexplicitly and deep-imports@simlin/diagram/Editor-- the package root would also pull inreset.css, a global page reset that must never land in someone else's notebook page. Mounts React 19 into a div appended toel; placeholder while the engine loads; failure text in the cell.src/WidgetApp.tsx-- The React shell: wrapper<div class="simlin-notebook-widget" data-lm-suppress-shortcuts data-theme=... style={position:relative;height:Npx;width:100%;background:var(--color-background)}>(the Editor's chrome is absolutely positioned against the wrapper; the wrapper paints the editor's page-background token itself -- the Editor root and canvas are transparent, so a forcedtheme="dark"in a light JupyterLab would otherwise show dark chrome over a white canvas; the token lives on this very element via the scoped theme.css and flips withdata-theme, and the loading/error placeholder inindex.tsxresolves the same theme so a dark notebook never gets a light box while the engine loads;data-lm-suppress-shortcutskeeps notebook shortcuts out of the widget -- but see the keyboard paragraph below: Lumino needs it on the FOCUSED element, so the wrapper'sonFocusCapturestamps it onto every element that receives focus inside the widget), the notice toast, and<Editor inputFormat="json" ...>keyed by revision + generation, rendered once the wrapper element exists and given it asportalContainer-- so the drawer, dialogs, menus and the autocomplete listbox render INSIDE the widget box (where the scoped tokens,data-themeand the shortcut-suppression attribute live; portaled todocument.bodythey sit outside the root class and everyvar(--*)is undefined) and position against it, not the viewport (JupyterLab's windowed notebook translates its viewport with atransform, which would displace fixed boxes) -- andshowHomeLink={false}(a notebook page has no/to exit to; the link would pushState the notebook away), andonReload(the Reload of the Editor's engine-unavailable notice): the default would reload the whole JupyterLab page, so the widget remounts the Editor on its seed -- the last state the kernel acknowledged -- by bumping the generation itself (remountFromis idempotent on the unchanged pair), carrying the live viewport, freeing an in-flight snapshot slot and publishing an empty selection, as a kernel remount does.widget.module.cssgives the wrapperoverflow: hidden(the surfaces are clipped to the box by design) andisolation: isolate(their z-index band stays local to the widget). Owns the one in-flight snapshot slot and the seed pair; subscribes to the trait change events,msg:custom(save replies, notices), and the host theme signals;onSave/onSelectionChangedwiring.src/widget-root-class.ts--WIDGET_ROOT_CLASS, shared by the shell and the build (the CSS scoping selector).src/engine-bootstrap.ts-- Page-global compiled-module cache (keyed by the built-against wasm sha256,WASM_IDENTITY/wasmCacheKey) + per-instanceready(); the inline-wasm global (takeInlineWasm, read at module evaluation) and the wasm request/reply over the comm with timeout.src/widget-core.ts-- Functional core: the viewport carry (storedViewport: the root view's viewBox/zoom in a project text with the reader's defaults -- omitted viewBox is 0/0/0/0, omitted zoom is 1;viewportToCarry(live, outgoingJson, incomingJson): the decision above), trait coercion (readTraits, which also runswithEditorView: a seed or push whose root modelmainhas no view gets an empty stock-flow view, because the Editor rendersviews[0]and is a blank canvas without it -- the kernel lays viewless models out before seeding, this is the defence in depth; text that already has a view comes back byte-identical so theclassifyPushpair comparisons hold),resolveTheme,wrapperStyle,parseWasmReply,parseNoticeMessage, the snapshot protocol (requestId,snapshotMessage,parseSaveReply,inFlightFor,replyIsFor,classifyPush,versionAfterReply), the snapshot size guard (MAX_SNAPSHOT_BYTES,snapshotWireSize,checkSnapshotSize,oversizeMessage,oversizeNotice,formatSize-- the last two mirror pysimlin'ssnapshot_wire_size/format_sizebyte for byte). No DOM, no React, no model.build/scope-css.ts-- The PostCSS plugin that confines global stylesheets to the widget root (see Hosting model).src/anywidget-model.ts-- The slice of anywidget'sAnyModelthis package uses (verified against anywidget 0.11.0), so no@anywidget/typesdependency.rsbuild.config.ts-- The single-file ESM build (see its header comment for every choice).SIMLIN_WIDGET_BACKEND=workerbuilds the measurement-only worker variant.e2e/support.ts-- The static harness the journeys share (serveHarness,mountWidget,widgetState, theHarnessWindowshape ofe2e/harness/).e2e/diagram-editing.spec.ts-- The diagram-editing journey (docs/design-plans/2026-09-10-diagram-editing-core.md): the real Editor and engine over a small hand-built project, driven with real pointer and key events, asserting on the rendered SVG (pathd, valve circles, cloud transforms, stock rects) and on the snapshots the fake kernel receives -- a stock drag bends its cloud-ended flow orthogonally with a perpendicular face exit; a click on a stock-attached flow arrowhead or a link arrowhead changes and saves nothing; a flow end dragged into empty space previews a cloud at the pointer and commits exactly the last preview frame, and the saved stock drops the flow; reattaching lands on the new stock's face and the saved stock lists it; a valve dragged perpendicular forms a bracket whose middle follows the pointer and drags back to straight; Escape cancels a live drag; a drawn flow ends in a cloud at the pointer and takes its typed name; undo and redo restore and reapply the geometry; no routed pipe crosses the body of a stock it was attached to. A separate test drags with Escape pressed mid-drag: the drag is cancelled. Screenshots andjourney.jsongo toe2e/.output/diagram-editing/or$SIMLIN_JOURNEY_OUTPUT.e2e/diagram-editing-perf.spec.ts-- Frame timing on C-LEARN (converted from its.mdlin Node; ~4000 view elements), framed with a viewBox equal to the measured canvas so nothing refits: a stock with the most attached flow ends, then a ~50-element rubber-band selection, each dragged for about two seconds of unpaced pointer moves. Reports rAF frame deltas and long animation frames while the pointer moves and, from each release, the in-page time until the widget sends the saved snapshot and the longest frame meanwhile, plus the browser and WebGL renderer (perf.json). Asserts the edits land, not a frame budget. Tracing is off for the file (test.useat the top level; Playwright snapshots the page per action and cannot switch it per describe).e2e/canvas-probe.ts-- The in-page SVG probe both diagram journeys read the canvas through (flow paths, valves, arrowheads, clouds, stock rects, links, model-to-client mapping, hit tests, the settle point), plusshotand the output directory.e2e/widget.spec.ts-- Playwright feasibility journey (pnpm test:e2e): servesdist/widget.js, the harness page, and the wasm from disk viapage.route(), imports the bundle through a blob URL per mount exactly as anywidget does, plays the kernel's side of the wasm handshake with a fakeAnyModel(e2e/harness/fake-anywidget-model.js), adds a variable through the real Editor UI, asserts the snapshot lands on the model, that the compiled module is cached under the key carrying the served wasm's sha256, that a second module instance reuses it (no second request), that a kernel push remounts, that nothing outside the harness is fetched, and -- loading the module with theinlinemode's base64 global prepended exactly asinline_esmdoes -- that the engine boots with no wasm request and the global is consumed. It also carries two layout-engine claims jsdom cannot make (src/diagram/CLAUDE.md, Hosting Requirements and Portals): in a 400px cell the details card scrolls inside the box, stays edge-aligned with the search bar, and its Delete button is reachable; and the hamburger drawer renders inside the widget box with an opaque, token-styled sheet (computed background and font), contained to the box (absolute, sheet from the box's left edge, backdrop covering the box, no Exit link), leaving the 48px backdrop strip in a 320px-wide cell; and the theming claim only a style engine can make: withtheme=darkthe label fill, the halo's flood colour and the wrapper background compute to their dark token values (light keeps the exact black/white), and a dark and a light widget on one page each reference their own canvas's halo filter (a per-root id would collide across the two React roots). Requires a priorpnpm buildhere and insrc/engine. On a host without Playwright's system libraries, pointPLAYWRIGHT_BROWSERS_PATHat a matching browser tree (nixpkgsplaywright-driver.browsersmatches the pinned Playwright revision).src/test-utils/--fake-model.ts(a TRANSPORT-FAITHFUL AnyModel: one in-flight trait sync with merge-on-buffer semantics, ordered custom messages, abusyKerneltoggle that queues everything untilreleaseKernel()-- or one message at a time withreleaseOne()-- and the kernel half of the protocol -- accept/saved, reject/rejected, both echoing the requestid,oversize/warn notice,kernelChange);editor-mock.tsx(reproducesProjectController.save()'s serialisation: one in flight, one queued flush of the latest state re-reading the acknowledged version; its "pan"/"pan-child" buttons report a committed viewport throughonViewportChangefor the root / a module, and it recordsinitialViewport);engine-mock.ts; wired byrstest.config.mtsaliases so the shell tests never touch the wasm.
pnpm test (rstest, jsdom): widget-core.test.ts covers every coercion/decision arm of the core (every classifyPush arm, reply parsing, every viewportToCarry arm, storedViewport's defaults and its no-root-view cases); engine-bootstrap.test.ts the handshake, timeout (and its clearing), page-global cache (identity-keyed: another build's module is not reused), retry semantics, and the inline global (consumed and cleared; compiled with no comm request; unusable payload falls back to the request); index.test.tsx the AFM lifecycle and the protocol against the transport-faithful model (the wrapper is the Editor's portalContainer and showHomeLink is false; one snapshot per edit with the acknowledged base, its own request id, and no trait write; a burst of 3 edits during a busy kernel -> exactly one follow-up snapshot carrying the latest state, accepted; a disk change racing an in-flight snapshot -> remount from authoritative, the old save resolves undefined at once and the slot is freed so the new Editor's FIRST edit is sent with the new base and accepted while the stale rejected (the freed snapshot's id) is ignored without a second remount, and a stale saved arriving after a later remount does not regress the seed; replies naming another request, or none, leave the in-flight save waiting for its own; two views of one model each resolve only their own replies, in the distinct-bytes interleaving (B remounted by A's accept, edits again before its own late reject, which is ignored) and the byte-identical one (the loser classifies A's push own-ack, is remounted by its own reject rather than wedged, and chains its next edit from the new base); a reject at an unchanged revision (the kernel failed before applying) -> no remount, no retry loop; kernel pushes remount once and idempotently; the Editor's Reload remounts on the acknowledged seed with nothing sent, repeatably; unmount resolves an in-flight save; a second concurrent onSave is refused; an edit above max_snapshot_bytes -> oversize report + toast, resolved unsaved, no in-flight slot left behind, accepted once the kernel raises the cap; exactly at the cap is sent; notices; height/theme/read_only; live theme switching with observer disconnect asserted; selection debounce, and a remount publishing an empty selection (superseding a pending one; own-ack/idempotent pushes publish nothing); the wrapper's background style; a kernel push that left the stored viewport unchanged (or unset) remounts on the live viewport while one that moved it remounts on the kernel's, a module's live viewport is not carried, and a reject re-seed carries too; cleanup removes every listener); build/scope-css.test.ts the CSS scoping over the real stylesheets, its zero-specificity :where() prefixing, and fail-closed on unknown at-rules.
pnpm test:e2e is the bundle-level journey above (its fake kernel accepts snapshots and replies saved); it is not part of pnpm test.
Measured with gzip -9, in KiB (bytes / 1024): the JS rows on 2026-08-18 (rsbuild 2.1.5 production build), the wasm-opt'd row on 2026-09-02 (the wasm-release cargo profile: opt-level = 3 with LTO and codegen-units = 1, wasm-opt version 125 with build.sh's -O3 flags), the gate row on 2026-09-05 (the wasm-gate profile: opt-level = 3, no LTO, parallel codegen).
Regenerate with ls -l / gzip -9 -c ... | wc -c on dist/widget.js, src/engine/core/libsimlin-browser.wasm.raw and a wasm-opt run of it; the KaTeX font share is the total length of the woff2 data: URIs in the bundle.
| Artifact | raw | gzip |
|---|---|---|
dist/widget.js |
1,539 KiB | 605 KiB |
| of which inlined KaTeX woff2 faces (20 files, base64 data URIs) | ~339 KiB | ~262 KiB |
libsimlin-browser.wasm, wasm-opt'd (mode stamp opt) |
9,299 KiB | 2,921 KiB |
libsimlin-browser.wasm, gate build (DISABLE_WASM_OPT=1: wasm-gate profile, no wasm-opt; what the pre-commit hook leaves in the engine's core directory) |
9,563 KiB | 2,401 KiB |
The gate build is 3% larger raw but 18% smaller gzipped than the shipped blob: LTO's cross-crate inlining and wasm-opt -O3's inlining and unrolling both raise the entropy of what is left.
It is not a shipping candidate (the workspace Cargo.toml's [profile.wasm-release] has the run-time numbers).
Which number matters depends on the channel: the widget comm carries the raw bytes (websocket frames are not compressed by default), so the wasm-opt'd blob is the one to ship; the wheel is a zip, where the difference is a wash.
Roboto is deliberately NOT bundled: the Editor's stylesheets fall back through Roboto, Helvetica, Arial, sans-serif, hosts that have Roboto (JupyterLab does not by default) use it, and shipping the four woff2 subsets simlin-serve self-hosts would add ~60 KB for a font the notebook chrome does not use.