Imported from mofojed/deephaven-plugin-speed-reader (
AGENTS.md). Install upstream withnpx skills add mofojed/deephaven-plugin-speed-reader. Copyright stays with the author.
Developing deephaven_plugin_speed_reader
Contributor guide: project layout, and how to build, run, test, and lint. For what the plugin does and how to use it, see README.md and SKILL.md.
Run / dev loop
One command builds the JS, (re)installs the wheel, and starts a Deephaven server with the repo
mounted as the data directory (PSK iris):
uv sync && uv run plugin_builder.py --dev
--dev is sugar for --reinstall --js --server. Useful variations:
| Command | When |
|---|---|
uv run plugin_builder.py --reinstall --js |
rebuild JS + reinstall (no version bump) |
uv run plugin_builder.py --reinstall |
Python-only change (skip JS build) |
uv run plugin_builder.py --dev --watch |
rebuild + restart on file changes |
uv run plugin_builder.py --dev --server-arg --port=9999 |
pass args through to the server |
--data-dir <path> mounts a different directory as the Deephaven data dir (default: repo root, so
storage/notebooks shows up in the Web IDE). storage/ is git-ignored and regenerated from
examples/ on each server launch — edit examples/, not storage/.
Project layout
src/deephaven_plugin_speed_reader/ Python package
__init__.py public API: exports `speed_reader`, `ELEMENT_NAME`
speed_reader.py the element: an @ui.component that packs its kwargs into
`ui.BaseElement(ELEMENT_NAME, ...)`. Also owns the
`ELEMENT_NAME` constant.
register.py registers the bundled JS with Deephaven
_js/dist/index.js built JS bundle (git-ignored; produced by setup.py)
src/js/src/ TypeScript source
index.ts plugin entry point
DeephavenPluginSpeedReaderPlugin.ts
registration: one `mapping` entry whose key must match
`ELEMENT_NAME` in speed_reader.py
SpeedReaderView.tsx the component: word splitting, the pivot-letter (ORP)
layout, the word timer, and the controls
plugin_builder.py build/install/run CLI (uv build + uv pip install + server)
setup.py packages src/js/dist into the wheel via package_js()
examples/ runnable panels (canonical; copied into storage/notebooks)
tests/ see Testing below
The Python side never loads the JS. It only names an element and hands over props; the JS side owns all the behaviour (timing, pivot maths, controls) and nothing round-trips to the server unless a callback fires.
The two cross-language contracts
- The element name.
ELEMENT_NAMEinspeed_reader.pymust equal themappingkey inDeephavenPluginSpeedReaderPlugin.ts, or the panel renders nothing. - The prop names.
ui.BaseElementcamelCases the Python kwargs and drops the ones left asNone, sodefault_wpmarrives asdefaultWpm. A prop the component does not read is silently ignored — no error anywhere.
tests/test_speed_reader.py reads the TypeScript source and asserts both, plus that the default
wpm agrees on each side.
Externals contract
src/js/vite.config.js marks react, react-dom and @deephaven/plugin as external, so the
bundle requires them from the host at load time. Everything else must stay bundled — in
particular the JSX runtime, which the host does not provide. The controls are plain HTML styled
with --dh-color-* variables rather than @deephaven/components, which keeps the externals list
(and the test harnesses) small. tests/loader/simulate-dh-loader.mjs guards this: it fails if the
bundle requires anything the host does not provide.
Environment
Managed with uv; all deps + dev tooling are in pyproject.toml.
uv sync creates .venv with the runtime deps and the dev group (ruff, ty, pytest,
deephaven-server, watchdog). This is a uv virtual project ([tool.uv] package = false), so
uv sync does not build the wheel — plugin_builder.py does.
JS deps install automatically on the first --js build.
Testing
# Python unit tests — the element name, prop names and defaults, checked against
# the TypeScript source. deephaven.ui is stubbed, so no server is needed.
uv run pytest -q
# Every examples/*.py, exec'd + rendered against a REAL in-process Deephaven
# server, so the real BaseElement prop marshalling is exercised
.venv/bin/python tests/verify_examples.py
# The bundle, loaded exactly as the Deephaven web client loads it (pure Node).
# Also checks the externals contract.
node tests/loader/simulate-dh-loader.mjs
# Browser tests of the built bundle (tier1, must pass)
cd tests/e2e && npm test
verify_examples.py boots a real server and drives the actual deephaven.ui Renderer, so it
catches contract drift the stubbed unit tests can't — it walks each rendered tree and fails on any
prop the component does not read.
The e2e harness loads the built bundle through new Function(module, exports, require, …), looks
the component up in the plugin's own mapping, and mounts it with real React 17, so tier1
exercises the real render path. Rebuild the bundle (cd src/js && npm run build) before running it.
Word timing is made deterministic with Playwright's virtual clock. Two things to know: the clock
must be paused (clock.install({ time }) + clock.pauseAt(time)) or it keeps ticking at the
real rate and races the assertions; and it does not drive React's scheduler (that uses
MessageChannel), so interactions that start or stop the word timer need a real-time settle — use
the clickControl helper rather than clicking directly.
Lint, format, type-check
uv run ruff check . # lint
uv run ruff format . # format
uv run ty check src # type check
JS: cd src/js && node_modules/.bin/tsc --noEmit (typecheck) and npm run build (bundle).
Everything else — TS, JS, HTML, Markdown, YAML — is formatted by Prettier from the repo root:
npx prettier@3 --write . # or --check .
.prettierrc.json mirrors @deephaven/prettier-config
(single quotes, arrowParens: avoid) — it is inlined rather than extended because there is no
package.json at the repo root to resolve it from. Keep it: without a checked-in config an editor's
default Prettier flips the whole tree to double quotes, and tests/test_speed_reader.py parses
these files.
The README GIF
_assets/speed-reader.gif is generated, not hand-recorded. Rebuild the bundle, then:
cd tests/e2e && npm run capture:gif # needs ffmpeg on PATH
It drives the same tier1 harness on the same paused virtual clock, so every frame lands on an exact word boundary. Regenerate it whenever the component's appearance changes.
Distributing
Bump the version in pyproject.toml, build (uv run plugin_builder.py --reinstall --js or
uv build --wheel), then upload the wheel from dist/. Publishing to PyPI happens automatically
on GitHub release via .github/workflows/publish.yml.
Debugging
- Panel is empty / import not found → the plugin isn't registered. Check the console for
Plugins loaded:including this plugin, or the settings panel (gear icon). Rebuild/reinstall and watch for errors. Confirm the Python package:uv pip list | grep speed-reader. - Panel renders but a prop does nothing → the prop name drifted. Python sends camelCase; check
it against
SpeedReaderViewProps.uv run pytest -qcatches this. - The speed slider snaps back → the component is controlled (
wpmis set) buton_wpm_changeis not wired to state, so Python keeps re-rendering the old value.