Skip to content

Workbench client

The workbench is a trame 4 server with a React client. Python builds every panel as a server template, and one Vite-built library of React components, named Ff, renders those templates in the browser. This page explains how a panel reaches the browser, how the client is built and checked, where the colours come from, how a save reloads the page, how the 3D view works and which upstream versions the code depends on. Workbench covers using the workbench, and the workbench API reference lists the signatures.

From a Python panel to the browser

Module Role
packages/femforge-workbench/src/femforge_workbench/serve.py show serves a Split, a factory, a catalogue card or the launcher; build turns a card into a shell (packages/femforge-workbench/src/femforge_workbench/serve.py:87-117)
packages/femforge-workbench/src/femforge_workbench/registry.py the catalogue: built-in cards plus the femforge.workbenches entry points, each target imported only when its card opens (packages/femforge-workbench/src/femforge_workbench/registry.py:291-319)
packages/femforge-workbench/src/femforge_workbench/app.py Workbench, the shell: the header, the dock, the panels and the persisted state
packages/femforge-workbench/src/femforge_workbench/dock.py the dock tree of Split, Tabs and Panel, its dockview layout JSON and the restore rule
packages/femforge-workbench/src/femforge_workbench/panels/ PanelComponent and the built-in panels
packages/femforge-workbench/src/femforge_workbench/widgets/ typed Python wrappers, one per React component of the client library
packages/femforge-workbench/src/femforge_workbench/widgets/module.py the trame module that serves the built client under /__ff/ and installs the Ff global

The shell runs trame with client_type="react" (packages/femforge-workbench/src/femforge_workbench/app.py:218). A panel reaches the browser in five steps:

  1. The constructor enables every widget library a panel may use: the Ff widgets, trame-rca and trame-vtklocal (packages/femforge-workbench/src/femforge_workbench/app.py:61-62, packages/femforge-workbench/src/femforge_workbench/app.py:256-257). A client loads only the modules enabled before it connected, and a card can open such panels later.
  2. add builds each Panel leaf through its builder and registers it before the next one, so a later panel sees the panels already standing (packages/femforge-workbench/src/femforge_workbench/app.py:440-455).
  3. Each PanelComponent builds its widgets inside its own DivLayout, whose template name is panel-<id> (packages/femforge-workbench/src/femforge_workbench/panels/__init__.py:183-192, packages/femforge-workbench/src/femforge_workbench/dock.py:89-91).
  4. The header holds one ff-dock element whose render prop is a react.Slot with a client.ServerTemplate named by the panel's templateName param (packages/femforge-workbench/src/femforge_workbench/app.py:298-310). The DockPanel component renders that slot for every dockview panel (packages/femforge-workbench/js/src/components/Dock.tsx:99-118).
  5. When the dock reports ready, the shell restores the saved layout or the authored one, then calls add_panel for every placement, which the dock ignores for an id it already holds (packages/femforge-workbench/src/femforge_workbench/app.py:709-721, packages/femforge-workbench/js/src/components/Dock.tsx:216-220).

A panel namespaces its state through key(name), which prefixes the panel id (packages/femforge-workbench/src/femforge_workbench/panels/__init__.py:198-200). callback and listen record what the panel registers, so dispose can unregister its triggers and state listeners when the tab closes (packages/femforge-workbench/src/femforge_workbench/panels/__init__.py:210-240). A panel rebuilt under the same id receives the disposed one through succeed, which the scene panel uses to keep its camera and transport (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:974-995).

from femforge_workbench.dock import Panel, Split
from femforge_workbench.panels import Cls, PanelComponent
from femforge_workbench.widgets.button import Button
from trame.widgets import html, react


class CounterPanel(PanelComponent):
    """A button that counts its clicks in a state variable of this panel."""

    count: int = 0

    def build(self) -> None:
        """Builds the button and the count beside it."""
        self.state[self.key("count")] = self.count
        with html.Div(classes=str(Cls.TOOLBAR)):
            Button("Add", size="xs", on_click=self.callback(self.add))
            html.Span([react.Bind(self.key("count"))], classes=str(Cls.CAPTION))

    def add(self) -> None:
        """Counts one click."""
        self.count += 1
        self.state[self.key("count")] = self.count


def counter() -> Split:
    """One counter panel."""
    return Split([Panel("Counter", CounterPanel)])

Worker threads

The trame server runs on one asyncio loop. A reloader or a monitor that works on another thread hands its result to the loop through marshal, which wraps call_soon_threadsafe (packages/femforge-workbench/src/femforge_workbench/app.py:683-688). A state change made from such a callback runs inside with self.state:, because only a trame handler flushes state on its own (packages/femforge-workbench/src/femforge_workbench/app.py:519-524).

The widget library

Each wrapper subclasses Element and passes locals() to it. Every declared parameter becomes a React prop: a trailing underscore is stripped, aria_label is renamed, the rest is camelCased, and a parameter named on_* becomes an event (packages/femforge-workbench/src/femforge_workbench/widgets/element.py:73-81, packages/femforge-workbench/src/femforge_workbench/widgets/element.py:102-121). model stays on the Python side and binds a state variable that the control writes in the browser (packages/femforge-workbench/src/femforge_workbench/widgets/element.py:18-19). A wrapper that needs a handle takes a ref from next_ref and calls component methods through it, as Dock does for addPanel and restoreLayout (packages/femforge-workbench/src/femforge_workbench/widgets/dock.py:51-72).

The TypeScript props are the contract. tests/workbench/test_widgets.py checks every wrapper parameter against the tag's props in the built props.json, and every Literal alias against the TypeScript union it mirrors (tests/workbench/test_widgets.py:31).

On the client, install registers every capitalised export of js/src/components/*.tsx and then of js/src/components/ui/*.tsx as ff-<kebab-case>; the first tag wins, so ff-table is the TanStack table and the shadcn namesake is skipped (packages/femforge-workbench/js/src/index.tsx:36-45).

The client build

packages/femforge-workbench/js/ is a Vite library build. It writes one UMD bundle that exposes the Ff global, keeps React and ReactDOM external, and emits into the widget package's serve/ directory with the base URL /__ff/ (packages/femforge-workbench/js/vite.config.ts:14, packages/femforge-workbench/js/vite.config.ts:34-54). The trame module serves that directory under the same route and lists the bundle and the stylesheet with the source hash as a cache buster (packages/femforge-workbench/src/femforge_workbench/widgets/module.py:12-15, packages/femforge-workbench/src/femforge_workbench/widgets/module.py:44-64). Git ignores the built directory (packages/femforge-workbench/.gitignore:3), and the Quality workflow builds it on every run (.github/workflows/quality.yml:32-36).

Built file Written by Read by
ff.umd.js, ff.css Vite; the bundle opens with a /* ff <hash> */ banner the page, through the trame module
version.json the dist plugin: the source hash, the bundle and the stylesheet names packages/femforge-workbench/src/femforge_workbench/widgets/module.py:29-36, the dev reload probe and both freshness checks
theme.json the dist plugin, from styles.css Palette
icons.json the dist plugin, from js/src/icons.ts the widget tests
props.json packages/femforge-workbench/js/scripts/emit-props.mts, after the Vite build the widget tests
THIRD_PARTY_LICENSES.txt the dist plugin the wheel
popout.html, fonts/ copied from js/public/ dockview popout windows, the stylesheet

The dist plugin writes version.json, theme.json, icons.json and the license file only after a bundle was written, so a failed build leaves the previous dist and its stamp in place (packages/femforge-workbench/js/scripts/vite-plugin-dist.mts:151-182). The license file joins the licenses Vite collects from the bundled modules with those of tailwindcss and dockview, whose CSS styles.css imports outside that scan, plus each package's NOTICE file (packages/femforge-workbench/js/scripts/vite-plugin-dist.mts:10-11, packages/femforge-workbench/js/scripts/vite-plugin-dist.mts:120-149).

The source hash

The hash is sha256, cut to 16 hex characters, over the sorted paths and LF-normalised bytes of js/src, js/public, js/scripts, the five config and lock files, and every .py file of the workbench package outside serve/ (packages/femforge-workbench/js/scripts/source-hash.mts:5-8, packages/femforge-workbench/js/scripts/source-hash.mts:44-61). The Python files count because Tailwind scans them for utility classes (packages/femforge-workbench/js/src/styles.css:4-5), so a Python-only change can change the stylesheet. That is also why the shell's class strings sit literally in Cls enums (packages/femforge-workbench/src/femforge_workbench/app.py:79-89).

Three places compare the stamp with the sources:

Check Where On a mismatch
npm run check-dist packages/femforge-workbench/js/scripts/check-dist.mts:1-19 exits 1
the hatch build hook packages/femforge-workbench/hatch_build.py:48-62 refuses the sdist or wheel; an editable install skips the check
the dev reload probe packages/femforge-workbench/src/femforge_workbench/dev.py:45-72 reloads the page with fresh assets

hatch_build.py repeats the hash in Python (packages/femforge-workbench/hatch_build.py:19-40), so the two implementations must change together. It also refuses a dist that lacks any of the eight required files (packages/femforge-workbench/hatch_build.py:16, packages/femforge-workbench/hatch_build.py:54-56).

Watch builds

npm run watch runs vite build --watch (packages/femforge-workbench/js/package.json:11). A watch build keeps the output directory between rebuilds (packages/femforge-workbench/js/vite.config.ts:43-44) and skips emit-props.mts, which only npm run build runs (packages/femforge-workbench/js/package.json:10). Run npm run build after you change the props of a component.

Theme and tokens

packages/femforge-workbench/js/src/styles.css is the single source of colour: the :root and .dark token blocks, the --ff-* metrics in pixels and the font stacks (packages/femforge-workbench/js/src/styles.css:41-153). At build time themeFrom converts each colour through culori to sRGB hex, reads each --ff-* pixel value as a number, and writes both schemes to theme.json (packages/femforge-workbench/js/scripts/vite-plugin-dist.mts:64-87).

On the Python side, load_tokens reads theme.json and Palette picks one scheme (packages/femforge-workbench/src/femforge_workbench/theme.py:170-177, packages/femforge-workbench/src/femforge_workbench/theme.py:206-243). The palette paints what the browser stylesheet cannot reach:

Consumer Method
plotly plotly_template, registered as the default template by install (packages/femforge-workbench/src/femforge_workbench/theme.py:273-294)
pyvista the scene panel's background and font colours (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:479-485)
xterm xterm_theme (packages/femforge-workbench/src/femforge_workbench/theme.py:296-298)
Monaco monaco_theme (packages/femforge-workbench/src/femforge_workbench/theme.py:255-258)

The ff-theme element toggles .dark and color-scheme on <html>, where Radix portals outside the layout root see them (packages/femforge-workbench/js/src/components/Theme.tsx:7-16). The shell mounts it in the root and in every panel, so a standalone ?ui= page and a popout window paint too (packages/femforge-workbench/src/femforge_workbench/app.py:314-316, packages/femforge-workbench/src/femforge_workbench/panels/__init__.py:190-191). popout.html copies the opener's scheme before its first paint (packages/femforge-workbench/js/public/popout.html:7-13).

The dev reload loop

Save What happens
a .py file under a card's roots its Reloader purges the modules under the roots, re-imports the target on a worker thread and hands the result to the server loop
a shell core module the --dev supervisor restarts the server process; the page follows it back
a file under js/ with npm run watch running version.json changes and the page re-fetches the assets and reloads

A card's roots are the femforge source root, the workbench source root and the target's own source root, which is its src directory in a src layout so sibling packages reload together (packages/femforge-workbench/src/femforge_workbench/reload.py:95-124). SaveWatcher debounces a burst of saves by 0.3 s and counts only a changed mtime, since watchdog also reports the access-time updates NTFS writes on reads (packages/femforge-workbench/src/femforge_workbench/reload.py:25-26, packages/femforge-workbench/src/femforge_workbench/reload.py:146-169). A reloader runs one rebuild at a time with at most one more queued (packages/femforge-workbench/src/femforge_workbench/reload.py:251-320). All reloaders share one purge per save under REBUILD_LOCK, so every open card ends on the same class generation (packages/femforge-workbench/src/femforge_workbench/reload.py:34-35, packages/femforge-workbench/src/femforge_workbench/reload.py:365-396).

The shell rebuilds a Split in place: it disposes the old panels, builds the new ones under the same ids, and their templates replace the running ones while the dock layout stays (packages/femforge-workbench/src/femforge_workbench/app.py:509-597). Changed panel ids close and reopen the card. A factory that raises leaves the old panels, prints the traceback and names the error in the status line (packages/femforge-workbench/src/femforge_workbench/reload.py:322-336, packages/femforge-workbench/src/femforge_workbench/app.py:486-489).

The shell core never reloads in process: SHELL_MODULES and the logging singleton survive every purge (packages/femforge-workbench/src/femforge_workbench/reload.py:40-44). Under --dev, the Supervisor runs the server as a child in its own process group and restarts it after a save of one of those files (packages/femforge-workbench/src/femforge_workbench/dev.py:85-159). It stops the child with a break signal so its exit hooks flush the persisted state, and kills the process tree after 5 s (packages/femforge-workbench/src/femforge_workbench/dev.py:33-34, packages/femforge-workbench/src/femforge_workbench/dev.py:112-119, packages/femforge-workbench/src/femforge_workbench/app.py:855-870). The child serves a page script that probes the server every second, reloads once the server answers again, and reloads when version.json changes (packages/femforge-workbench/src/femforge_workbench/dev.py:45-72).

The 3D path

Each scene panel owns one off-screen pyvista plotter and shows it through one of two transports, chosen per panel by its ViewMode toggle (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:89-93).

Transport Mode How it works
VTK.wasm mirror client, the default trame-vtklocal serializes the render window into the wasm32 runtime in the browser, which renders and interacts locally (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:648-668)
trame-rca stream server the server renders, encodes jpeg frames at a 30 fps target and sends them; a held button caps the frame at 2 Mpx until 0.4 s after the release, then one 2x still follows (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:385-461, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:620-646)

Before every mirror sync, mirror_friendly prepares the scene (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:216-292):

  • It turns depth peeling off. VTK.wasm renders through OpenGL ES 3, which has no dual depth peeling, so a peeling renderer logs an error per frame and sorts nothing (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:218-219).
  • It moves every polydata's cell arrays to 32-bit storage. VTK serializes a 64-bit vtkIdTypeArray under that class name and the wasm32 runtime reads it with its own 32-bit vtkIdType, so every line or polygon would run from its first point to point 0 (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:221-230, tests/workbench/test_scene.py:359).
  • It replaces every non-polydata input of a vtkDataSetMapper with its surface and point scalars. VTK serializes such an input through a vtkGeometryFilter it frees on return, so the mirror receives objects that no longer exist and stalls (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:283-286).
  • It sums the cells and memory the mirror would fetch. A scene above 64 MB streams instead, without persisting that choice (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:61, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:765-775).

push runs mirror_friendly again before each sync, since a renderable may add actors after its draw, and then refresh_composites (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:777-789). VTK.wasm 9.7 writes a block's visibility without Modified(), so a composite mapper whose attributes changed gets a new container over the same blocks (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:295-314, tests/workbench/test_scene.py:384).

One page holds one mirror. VTK.wasm hands browser events to the last view mounted on a page, so the newest scene panel claims the mirror and every older one switches to the stream and says so in its status line (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:523-528, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:677-682). The shell builds panels one at a time for this reason (packages/femforge-workbench/src/femforge_workbench/app.py:440-441).

The mirror's camera and canvas reach Python through events. SceneSync reports the canvas box in device pixels, the plotter window takes that size, and the first size re-fits the camera (packages/femforge-workbench/js/src/components/SceneSync.tsx:145-148, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:842-867). SceneSync also turns the corner triad with the camera inside the browser, through a synchronous wasm invoke (packages/femforge-workbench/js/src/components/SceneSync.tsx:99-106, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:348-382). A saved camera is copied into the live one with DeepCopy, because assigning a camera makes a new object active under an id the browser never saw (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:813-815).

packages/femforge-workbench/src/femforge_workbench/b3d_pv.py draws a build123d compound for the scene: each unique shape is meshed once and every occurrence becomes an actor with its own world matrix and colour (b3d_pv.py:18-19).

Persistence

The shell keeps UI state through the UiStateStore protocol, never browser storage, so a ?ui= tab and a popout window share it (packages/femforge-workbench/src/femforge_workbench/persist.py:33-52). LocalJsonStore writes one JSON file per user, workbench and key under the per-user config directory femforge-workbench, at most once per 0.5 s, and a load sees a save not yet written (packages/femforge-workbench/src/femforge_workbench/persist.py:16-17, packages/femforge-workbench/src/femforge_workbench/persist.py:60-130). The scope is the OS user and the workbench name (packages/femforge-workbench/src/femforge_workbench/persist.py:55-57, packages/femforge-workbench/src/femforge_workbench/app.py:225). The server's exit hook flushes pending saves (packages/femforge-workbench/src/femforge_workbench/app.py:817-823).

Document Writer Holds
layout the shell the dockview layout the dock reports, restored only when it names exactly the authored panels, with popout and floating groups folded into the first group (packages/femforge-workbench/src/femforge_workbench/app.py:723-730, packages/femforge-workbench/src/femforge_workbench/dock.py:243-295)
opened the shell the cards open in the dock, in opening order, so a restart reopens them (packages/femforge-workbench/src/femforge_workbench/app.py:603-633)
table-<panel id> the table panel the layout the client reports: column order and visibility, group fields, sort and filters (packages/femforge-workbench/src/femforge_workbench/panels/table.py:21-26)
view-<panel id> the scene panel the transport the user chose (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:71, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:530-536, packages/femforge-workbench/src/femforge_workbench/panels/scene.py:696-697)

Pinned upstream versions

The workbench pins the minors whose behaviour its workarounds depend on (packages/femforge-workbench/pyproject.toml:24-41). Re-check each workaround before you raise a pin.

Pin Workaround it protects
vtk>=9.7.1,<9.8 32-bit cell storage for the wasm32 runtime (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:221-230); the composite refresh for block visibility (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:295-314); the surface swap for dataset mappers (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:283-286); the wasm runtime that trame-vtklocal downloads matches this VTK version
trame-vtklocal>=1.7.1,<1.8 tolerate_dropped_objects: 1.7.1 reads interactor outside the branch that assigns it, so a status request for a released window raises; the patch answers an empty scene (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:317-345)
pyvista[colormaps]>=0.49.0,<0.50 the plain vtkRenderWindowInteractor and C++ trackball style, since pyvista's Python-subclassed style is mirrored verbatim and never bound to browser events (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:486-489); the per-panel font copy, since the theme shares its font object with pyvista's global theme (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:543-549)
build123d>=0.13,<0.14 b3d_pv.py reads the private _wrapped attribute and meshes through to_vtk_poly_data (b3d_pv.py:224-238); the core package pins the same minor (pyproject.toml:25)

trame, trame-client, trame-server and trame-rca carry floors only. Two trame-rca details are coded around: it declares on_stats under its Vue name only, so the scene panel adds the React name (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:632-633), and it sizes a frame by width alone, so the image style fixes both dimensions (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:68-69).

Traps

  • SceneSync waits until the mirror's wasm session runs its event loop and holds the camera and triad matrix, since a state read on an object the browser has not built logs a vtkObjectManager error (packages/femforge-workbench/js/src/components/SceneSync.tsx:108-116).
  • dockview reports visibility only on a change, so DockPanel announces a panel visible when it mounts (packages/femforge-workbench/js/src/components/Dock.tsx:106-107); the shell keeps the visible ids for panels rebuilt in place (packages/femforge-workbench/src/femforge_workbench/app.py:236-237).
  • The xterm terminal has no mount event; the log panel listens for trame's mounted life-cycle event and replays its buffer (packages/femforge-workbench/src/femforge_workbench/panels/log.py:37-39).
  • A vtk-local view mounted into a display: none host has no canvas and fails to start, so switching transport unmounts the other view (packages/femforge-workbench/src/femforge_workbench/panels/scene.py:709-723).
  • Enabling trame-vtklocal fetches the wasm32 runtime for the installed VTK version, about 80 MB, on first use; the workbench tests point VTK_WASM_DIR_OVERRIDE at an empty directory to skip it (tests/workbench/conftest.py:11-17).
  • dockview fires onDidRemovePanel for its own teardown too, so the dock counts its own calls and reports only a user close (packages/femforge-workbench/js/src/components/Dock.tsx:190-191, packages/femforge-workbench/js/src/components/Dock.tsx:207-209).
  • The dock uses defaultRenderer="always", which keeps inactive tabs mounted (packages/femforge-workbench/js/src/components/Dock.tsx:270).
  • A JS-expression Bind that builds an array hands Radix a fresh array on every render, and a controlled value never settles; keep the array in state (packages/femforge-workbench/src/femforge_workbench/panels/field_viewer.py:471).
  • trame's own --app flag means pywebview, so run strips it from sys.argv (packages/femforge-workbench/src/femforge_workbench/app.py:875-876).
  • The supervisor polls its child every 0.25 s, since a bare wait would hold Ctrl-C on Windows (packages/femforge-workbench/src/femforge_workbench/dev.py:23-24).
  • The reload probe sends HEAD to index.html, since the server root answers HEAD with 403 (packages/femforge-workbench/src/femforge_workbench/dev.py:27-28).
  • Chrome raises a benign ResizeObserver loop error on busy resizes, and the trame client forwards window errors to the server log, so the client drops it (packages/femforge-workbench/js/src/index.tsx:9-16).