Skip to content

Latest commit

 

History

History
180 lines (150 loc) · 42.1 KB

CLAUDE.md

File metadata and controls

180 lines (150 loc) · 42.1 KB

@simlin/notebook-widget

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.

Hosting model (why everything here looks the way it does)

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.url is 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 ./ against import.meta.url, which THROWS for blob URLs at module init -- tools.cssLoader.esModule: false keeps url() 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 through tools.postcss; CSS Modules are skipped, their classes are hashed already). build/scope-css.test.ts runs 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/engine wasm singleton. The only page-wide sharing possible is through globalThis, so engine-bootstrap.ts caches the COMPILED WebAssembly.Module (the expensive part) under globalThis["__simlinWidgetWasmModule:<sha256>"] -- keyed by the sha256 of the libsimlin-browser.wasm the bundle was built against, baked in by rsbuild.config.ts (source.define SIMLIN_WIDGET_WASM_SHA256; the build fails if the wasm is missing) -- and each instance instantiates its own engine from it via ready(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.json records both hashes).
  • initialize must return fast. anywidget rejects a model whose module load plus initialize exceed ~2 s (runtime.ts, AbortSignal.timeout(2000)), after which every comm message for that model is dropped. initialize only kicks off the engine bootstrap; render awaits it. anywidget also queues comm messages until the module has loaded (_handle_comm_msg awaits runtime.ready), so an early kernel reply is safe.

Engine strategy

  • Wasm flavor: @simlin/engine/internal/wasm is aliased to @simlin/engine/internal/wasm.supplied -- no bundled artifact, no asset fetch; init() without a source throws. The bootstrap hands ready() the shared compiled module.
  • Backend: @simlin/engine/internal/backend-factory is 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 against import.meta.url).
  • Inline wasm (SIMLIN_WIDGET_ASSET=inline, pysimlin _widget_core.inline_esm): the kernel prepends globalThis.__simlinWidgetInlineWasm = "<base64 wasm>"; to the module text, so the artifact is on the page when the module evaluates. engine-bootstrap.ts reads 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), the bundled/url modes: the first instance on the page model.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 as DataViews 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 --execute with the default store_widget_state=True exports 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 and send on a comm-less model is a silent no-op); a failed shared promise is dropped so a later widget retries.

Model contract

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.

Kernel obligations (MUST)

  1. Seed: set project_json and revision before the widget is displayed. The Editor mounts the root model's first view, so a model with no view SHOULD be laid out first (pysimlin Project._ensure_view, a committed change like auto_layout()); a seed that arrives viewless anyway is repaired with an empty view on the widget side (withEditorView).
  2. Exactly one reply per snapshot, echoing its id. Wrap the whole handler so that ANY exception (parse failure, write error, a bug, even a KeyboardInterrupt that 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 following notice failing to send) -- plus a notice carrying the error. The reply's id is the snapshot's id exactly as received (any JSON value; omitted only when the snapshot carried none): the widget resolves a save ONLY on the reply whose id matches 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, and ProjectController runs one save at a time, so every later save of that view hangs with it until it is remounted. A malformed snapshot (bad base/json) that carries an id is still rejected WITH that id. revision in a reply is a JSON integer.
  3. Accept a snapshot whose base equals the current revision: apply and write the file, then inside ONE hold_sync() assign project_json = <the exact bytes received> and revision += 1 (plain assignments -- the frontend never set this trait, so there is no property lock and no send_state is needed), then send {type:'saved', revision, id}. The state message SHOULD leave before saved (exit the hold_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 then saved; the widget tolerates the reverse (it adopts the accepted state from saved itself), 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.
  4. Reject a snapshot whose base is 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 a notice. A kernel that raised AFTER applying (a bug between apply and reply) must NOT reply rejected: that is obligation 5's situation -- the change is real -- and a rejected there 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 at revision + 1 (best effort) and send {type:'saved', revision, id} plus a warn notice.
  5. 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 + 1 and sends {type:'saved', revision, id} exactly as in obligation 3, plus a {type:'notice', level:'warn'} naming the error and the fix (model.save()). Replying rejected here would leave the widget's acknowledged base one behind the kernel's and every later save would be rejected as stale.
  6. Own changes (Python edit(), a disk reload): assign both traits in one hold_sync(); add a {type:'notice', text:'Updated on disk'} for disk.
  7. 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.
  8. Wasm: answer {type:'wasm'} with a custom message {type:'wasm'} whose first binary buffer is libsimlin-browser.wasm, or {type:'wasm', error: string} -- in every asset mode, including inline (the widget falls back to the request when the inline payload is unusable).
  9. Only the kernel ever writes project_json and revision.
  10. Size: seed max_snapshot_bytes (default 8 MiB) below the notebook server's browser->kernel websocket limit (tornado websocket_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; pysimlin snapshot_wire_size, JS snapshotWireSize), so no other content-dependent inflation remains. Answer an {type:'oversize', bytes} report with a warn notice and NOTHING else -- no saved/rejected: the widget resolved that save itself and has nothing in flight. pysimlin also emits a RuntimeWarning, 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 a project_json above the cap, since every editor edit of that model will be refused; the warning is attributed to the user's cell (pysimlin user_stacklevel(), which skips pysimlin's own frames and IPython's display-formatter frames -- attributed to formatters.py it would be one source location for every display and Python's once-per-location filter would show it once per kernel session).

Widget guarantees

  • At most ONE snapshot in flight per view, and replies are matched by id. onSave sends {type:'snapshot', id, base, json} and returns a promise that resolves ONLY when the reply echoing that id arrives (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 matching id is treated as a reject, not ignored (it still consumes the one reply the snapshot is owed; ignoring it would hang the save queue). ProjectController already 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 it undefined so nothing dangles, a reply arriving with nothing in flight is ignored, and an onSave after unmount (a queued flush racing the dispose) is refused with undefined rather than sent. A second onSave while 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 one hold_sync but 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 is own-ack -- keep the live Editor and its undo history, but the seed is adopted only by the matching saved reply, 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 is rejected, and had the pair been adopted as the seed that reject would find nothing to remount and leave the Editor acknowledged at base with every later save stale (a repeat of the same push still classifies own-ack, so nothing remounts meanwhile); if it equals the seed pair (the pair a saved reply adopted), nothing; otherwise remount <Editor key=revision#generation> on it (kernel edit, disk change -- including one that raced an in-flight snapshot). Push-then-saved and saved-then-push therefore both end with one live Editor and no remount. A remount while a snapshot is in flight resolves that save undefined at 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's id, matches nothing in flight, and is ignored -- it neither remounts (the push already did) nor adopts its pair as the seed (a late saved for 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 rejected re-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 said rejected is handled (the push classifies own-ack, the seed is not adopted, so the reject remounts on the kernel's pair at base + 1) but not for free: a rejected there 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); generation makes the key change even when the revision number does not.
  • Never sends a snapshot above max_snapshot_bytes (checkSnapshotSize on the wire size -- JSON.stringify then UTF-8 bytes via TextEncoder, mirroring pysimlin's snapshot_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. Instead onSave sends {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 from formatSize, KiB below 1 MiB else MiB to one decimal, round-half-to-even -- byte-identical to pysimlin's format_size on the fixture list pinned in both suites, so the kernel's notice and the toast collapse into one), and resolves undefined: 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; only selection (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 this selection/Model.selection would 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.setViewport persists 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 Python edit(), 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. WidgetApp keeps the live Editor's last committed viewport (onViewportChange, once per settled gesture/fit, never per frame) and remountFrom hands it to the new mount as initialViewport when viewportToCarry (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.ts asserts it in a real JupyterLab (shift-pan, Python edit(), 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's body[data-jp-theme-light] (a MutationObserver) else prefers-color-scheme (a matchMedia listener), 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).

Keyboard: the notebook's shortcuts vs the Editor's (AC2.6)

Two mechanisms, both needed, both only observable in a real JupyterLab (src/pysimlin/e2e/notebook-editor.spec.ts, the AC2.6 test):

  1. 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 (x cut, d d deleted) while Delete reached no Editor.
  2. 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-shortcuts and otherwise matches the element against every binding selector; the notebook's command-mode bindings (selector .jp-Notebook.jp-mod-commandMode:not(.jp-mod-readWrite) :focus in JupyterLab 4.6's notebook-extension tracker.json schema; text fields are exempt because the Notebook toggles jp-mod-readWrite while 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: WidgetApp stamps the attribute onto the target of every focus event inside the widget (stampShortcutSuppression, onFocusCapture on 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.

Known limitation: two views of one model

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.

How the assets reach the wheel

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.

Files

  • src/index.tsx -- AFM entry, export default { initialize, render }. Imports @simlin/diagram/theme.css and KaTeX's katex.min.css explicitly and deep-imports @simlin/diagram/Editor -- the package root would also pull in reset.css, a global page reset that must never land in someone else's notebook page. Mounts React 19 into a div appended to el; 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 forced theme="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 with data-theme, and the loading/error placeholder in index.tsx resolves the same theme so a dark notebook never gets a light box while the engine loads; data-lm-suppress-shortcuts keeps notebook shortcuts out of the widget -- but see the keyboard paragraph below: Lumino needs it on the FOCUSED element, so the wrapper's onFocusCapture stamps 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 as portalContainer -- so the drawer, dialogs, menus and the autocomplete listbox render INSIDE the widget box (where the scoped tokens, data-theme and the shortcut-suppression attribute live; portaled to document.body they sit outside the root class and every var(--*) is undefined) and position against it, not the viewport (JupyterLab's windowed notebook translates its viewport with a transform, which would displace fixed boxes) -- and showHomeLink={false} (a notebook page has no / to exit to; the link would pushState the notebook away), and onReload (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 (remountFrom is 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.css gives the wrapper overflow: hidden (the surfaces are clipped to the box by design) and isolation: 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 / onSelectionChanged wiring.
  • 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-instance ready(); 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 runs withEditorView: a seed or push whose root model main has no view gets an empty stock-flow view, because the Editor renders views[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 the classifyPush pair 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's snapshot_wire_size/format_size byte 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's AnyModel this package uses (verified against anywidget 0.11.0), so no @anywidget/types dependency.
  • rsbuild.config.ts -- The single-file ESM build (see its header comment for every choice). SIMLIN_WIDGET_BACKEND=worker builds the measurement-only worker variant.
  • e2e/support.ts -- The static harness the journeys share (serveHarness, mountWidget, widgetState, the HarnessWindow shape of e2e/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 (path d, 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 and journey.json go to e2e/.output/diagram-editing/ or $SIMLIN_JOURNEY_OUTPUT.
  • e2e/diagram-editing-perf.spec.ts -- Frame timing on C-LEARN (converted from its .mdl in 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.use at 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), plus shot and the output directory.
  • e2e/widget.spec.ts -- Playwright feasibility journey (pnpm test:e2e): serves dist/widget.js, the harness page, and the wasm from disk via page.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 fake AnyModel (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 the inline mode's base64 global prepended exactly as inline_esm does -- 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: with theme=dark the 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 prior pnpm build here and in src/engine. On a host without Playwright's system libraries, point PLAYWRIGHT_BROWSERS_PATH at a matching browser tree (nixpkgs playwright-driver.browsers matches 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, a busyKernel toggle that queues everything until releaseKernel() -- or one message at a time with releaseOne() -- and the kernel half of the protocol -- accept/saved, reject/rejected, both echoing the request id, oversize/warn notice, kernelChange); editor-mock.tsx (reproduces ProjectController.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 through onViewportChange for the root / a module, and it records initialViewport); engine-mock.ts; wired by rstest.config.mts aliases so the shell tests never touch the wasm.

Tests

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.

Size

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.