@tenmol/web
The PyMOL front-end: a React app that owns the window and nothing else. The renderer is@tenmol/viewport, the transport is @tenmol/client, the wire types
are @tenmol/protocol, the state is @tenmol/stores. Everything the user
actually interacts with is a feature, discovered from disk.
Shape
src/app/App.tsx is a provider wrapped around a shell —
<BridgeProvider><AppShell/></BridgeProvider> — and nothing else. It does not
change as features land, and that is the point.
src/app/session.ts is a module singleton — one socket, one set of stores,
one poll loop, created on first import. The product is “one PyMOL process, one
browser client”, and a second connection would be a second consumer of a
destructive feedback drain. It is also what makes React 19 StrictMode’s
double-mount a non-event. Features call useSession() and then run (a typed
command line, {t:'do'}), act (a UI action, {t:'call'}) or call; nothing
outside src/app/ imports @tenmol/client.
src/app/hooks.ts is the React binding for @tenmol/stores — about thirty lines
of useSyncExternalStore, in place of a state library.
The layout is PyMOL’s, not a web app’s
Qt PyMOL is aQMainWindow with the GL widget as the central widget and the
External GUI as a dock. Everything in the right-hand column — object panel,
wizard, mouse-mode block, movie controls — is not Qt at all: PyMOL draws it
inside the GL viewport as 2-D Blocks stacked bottom-up by OrthoLayoutPanel()
(packages/engine/layer1/Ortho.cpp). Here those blocks are real DOM, in a column
of width internal_gui_width (default 220), pinned into OrthoLayoutPanel’s
order by CSS order because one of them is portalled in by another feature.
The one value the shell writes to internal_gui is 0. Measured: with
internal_gui 1 and the default width, an 800x600 window reports a 580x600
scene, and every mouse coordinate the browser forwards is then wrong by 220 px.
The column is our DOM; PyMOL’s own copy must stay off. A non-zero value
arriving from PyMOL (someone typed set internal_gui, 1) turns our column on and
is pushed straight back to 0. internal_gui_width is written back as-is, so
a .pse and a headless pymol -c see the width the browser is showing.
The External GUI is docked at the bottom rather than Qt’s default top,
because the in-viewport prompt and scrollback it duplicates are drawn at the
bottom of the scene. Its dockable/visible state machine (shell/extGuiDock.ts)
is a transcription of toggle_ext_window_dockable, Ctrl+E and all.
The status strip at the very bottom is the one deliberate addition: Qt PyMOL has
no status bar, but a desktop PyMOL never had a transport to report.
Adding a feature
Do not editsrc/features/registry.ts. Create
src/features/<slot-id>/register.ts with a default export of FeatureModule and
you are installed:
import.meta.glob('./*/register.ts'), so creating the directory is
the whole installation step and no shared file is edited. Loading is lazy on
purpose: an eager glob would make one feature’s broken import a blank
application; lazily, a failure is confined to its slot and shown there by the
error boundary.
The registry declares 25 slots across six regions (menubar, viewport,
internal-gui, external-gui, overlay, service). 23 are installed; the
two without a directory are picking (client-side picking lives in
@tenmol/viewport/picking, routed by registerPickRoute) and plugins (the
plugin-manager slot covers it). A declared slot with no directory renders its
absent note rather than a blank space — an unbuilt feature must be visibly
unbuilt. A directory no slot declares is reported through UNDECLARED_FEATURES
rather than silently ignored.
Three states are distinguishable in the DOM, which is what lets the e2e suite
assert on them: .feature-failed (threw, boundary caught it), .feature-absent
(no directory), anything else (mounted). A directory whose register.ts is
broken shows up as absent, which looks like unbuilt work — that is the
failure mode to watch for.
src/features/mouse/ has no register.ts and is not a slot: it is a shared
table module other features import.
Cross-feature calls go through shell/panelHooks.ts
A menu leaf in one feature has to open a dialog owned by another, and neither may
import the other. panelHooks.ts is the registry for that: a feature calls
registerMenuHook('file_open', fn) from its own directory and MenuBar merges
whatever is registered on top of what it implements itself, so a leaf goes live
the moment its owner lands.
Opening an overlay needs two steps, and the seam handles both: openPanel(id)
mounts the slot, and the intent is queued against that mount — the slot is a
React.lazy behind a Suspense, so at the moment openPanel returns the module
has not been fetched and any event dispatched at it would land on nobody.
FeatureSlot reports the mount and the queue drains then.
Two directories that are dead
src/layout/ and src/bridge/ are the wave-0 scaffold. Nothing imports
them — the live equivalents are src/shell/ and src/app/. They still
typecheck and lint, so they cost nothing but confusion; do not extend them, and
do not take src/layout/placeholderData.ts as evidence that anything still
renders placeholder data.
Dev-only query switches
Handled insrc/features/viewport/devFixtures.ts:
?viewportFixtures=<names> (load pre-encoded geometry frames),
?viewportHandle=1 (publish the live ViewportHandle on
window.__tenmolViewport — what the e2e suite measures), ?viewportPull=off
(disable the dev PNG-pull fallback) and ?viewportModeP=off. The fixture and
pull sources are inert in a production build because vite defines the frame
directory as empty there.
?token=... is consumed once by src/app/config.ts, stashed in localStorage
and then stripped from the address bar so it does not reach history or a
screenshot.
Dependencies
react, react-dom, and the workspace packages @tenmol/protocol,
@tenmol/client, @tenmol/stores. No Tailwind, no component library, no state
manager.
@tenmol/viewport is deliberately not in package.json; vite resolves the
bare specifier through an alias in vite.config.ts. Because of that, deep
subpath imports (@tenmol/viewport/input, @tenmol/viewport/picking) do not
resolve from this app — the alias points at one file. Code that needs them uses a
relative path into packages/viewport/src/, and says so where it does. Adding
"@tenmol/viewport": "workspace:*" here and dropping the alias is the fix.
Tests
*.dom.test.ts(x) runs
under jsdom; everything else runs under node. That split is per file, not per
package, so no vitest config has to change when you add one.
Two stores are tested from here rather than from their own package —
@tenmol/stores/console and @tenmol/stores/settings — because their tests
exercise the store together with the feature that wires it.
The end-to-end suite is separate and documented in e2e/README.md.