Skip to main content

textual-wasm

ci docs python license

Ship one Textual application as both a terminal TUI and a fully client-side web page — same source, no server-side Python process, no fork of Textual.

Documentation · Live demos · Porting guide

textual-wasm build myapp.main:App myapp -o dist/   # a static directory
textual-wasm dev dist/                             # http://127.0.0.1:8000

The output is static files. Pyodide and xterm.js come from a pinned CDN; your application is copied in as source. There is no build toolchain at the far end and nothing for the person deploying it to install.

Textual itself is untouched. The extension point is a public one — TEXTUAL_DRIVER=module:Symbol (textual/app.py:1585) — so there is no patch to rebase.

The four commands

textual-wasm doctor <module:App> What will break, with a file:line. Reads imports, call sites, and dependencies.
textual-wasm build <module:App> <package> -o dist/ A static site. Bare page by default; --title and --template if you want otherwise.
textual-wasm dev dist/ Serve it locally. Standard library only.
textual-wasm check --app <module:App> Run it on every runtime available and compare.

What check actually checks

Four runtimes, and two comparisons that mean something:

Leg Runtime Settles
native CPython The baseline.
wasm Pyodide under Node Everything on the Python side, in CI, with no browser.
browser Chromium, Firefox and WebKit over a real build Rendering, and whether the engines agree.
terminal a real pty via tmux, on Textual's own driver What a user would actually see.

native against wasm is compared check-by-check, fact-by-fact and grid-by-grid. terminal against browser is compared cell-by-cell — that one is the render claim, and it is made against a real terminal rather than a replay because three different character-width tables are involved and they do not share code.

A runtime this machine cannot reach is reported as skipped, with the command that would enable it, and the rest still produce a verdict. --strict makes a skip a failure, which is what CI wants.

textual_wasm.app:SpikeApp at 80x24
┏━━━━━━━━━━┳━━━━━━━━━┳──────────────────────────────────────┓
┃ runtime  ┃ status  ┃ detail                               ┃
┡━━━━━━━━━━╇━━━━━━━━━╇──────────────────────────────────────┩
│ native   │ ran     │ 8 checks, 0 failed                   │
│ wasm     │ ran     │ 0 check(s) failed                    │
│ browser  │ ran     │ 19 rows rendered                     │
│ terminal │ ran     │ tmux 3.7c                            │
└──────────┴─────────┴──────────────────────────────────────┘
identical: terminal and browser render the same
equivalent across 4 runtime(s)

Nothing the probe measures is asked of your app: the timer is scheduled by the probe, the resize is read back off the Screen it laid out, and input is judged by what appears on the grid. An app that has never heard of this project is measured by exactly the code that measures the one that ships with it.

Diagnostics

Pyodide's most dangerous failures are the ones that raise nothing. os.system() returns 0 and does nothing; loop.run_in_executor() ignores the executor and runs inline on the only thread, so code written to keep a UI responsive freezes the page instead.

textual_wasm.diagnostics.install() manufactures a loud, specific failure for each, naming the substitute. diagnostics.attach(app, driver) moves crash output off stderr — which under Pyodide is a browser console nobody is watching — and into the terminal the user is looking at. The build output does both for you.

Every one of those claims is measured, not transcribed, and the porting matrix is generated from the same registry the analyser and the guards read. A test fails when it drifts; a characterisation suite re-measures the registry inside a real Pyodide. Pyodide's own documentation lists four modules as removed that import fine in 314.0.6, which is what a hand-maintained table gets you.

Documentation

The documentation site is itself the proof: every demo on it is a real build of a real Textual app, running in your browser, served as static files by GitHub Pages.

  • Quickstart — a new app in a browser in five minutes.
  • Porting guide — moving an app you already have.
  • Embedding — custom pages, and Textual inside Svelte, Vue or plain HTML.
  • Limitations — what does not work, organised by whose constraint each one is.
  • Browser support — measured across three engines, and what that measurement does not cover.
  • Feasibility study — the architecture audit, what the spike measured, and the claims it corrected.

Examples

simple-app A task list. The smallest complete thing.
embedded-page The terminal as one component of an article, driven by HTML buttons.
svelte-app Mounted in a Svelte 5 component, with Svelte state around it.

Each is a self-contained project with its own pyproject.toml, README and linting — copy one out and it works.

How the pieces fit

bootstrap.py   TEXTUAL_* env, applied before the first `import textual` (it caches at import)
polyfills.py   runtime bugs, quarantined and reported
driver.py      WasmDriverBase  ->  CaptureDriver (sink: a list)
browser.py     WasmDriverBase  ->  BrowserDriver (sink: xterm.js)
target.py      which app, and how a harness knows it drew
probe.py       the experiment, over any app
check.py       every runtime this machine has, and the comparisons
substitutions  the registry: one source for the analyser, the guards and the docs

Nothing in the probe, the driver, the app or the report may branch on sys.platform — a semgrep rule enforces it. Runtime differences are confined to polyfills.py and recorded in the report, so a WASM run can never look accidentally native.

Development

poetry install
pnpm install                                 # only for the wasm and browser legs of `check`
poetry run pytest                            # includes a selftest of the semgrep rules
pnpm lint:all                                # eslint (css + js), stylelint, principled-css
poetry run textual-wasm pins                 # regenerate wasm-requirements.txt
poetry run textual-wasm matrix -o docs/porting-matrix.md
git config core.hooksPath .githooks          # lint, types, complexity, policy, tests
./scripts/run-spike.sh                       # the whole matrix, strictly

Release files for textual-wasm 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for textual-wasm 0.1.0
File Size Uploaded
textual_wasm-0.1.0.tar.gz 108.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for textual-wasm 0.1.0
File Interpreter ABI Platform
textual_wasm-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 243.5 kB

Release files / textual_wasm-0.1.0.tar.gz

Download URL textual_wasm-0.1.0.tar.gz
Size 108.1 kB
Tags Source
SHA-256 checksum
How to use checksums
6e2d046f6b17a0ae8a741afd7136bce746d67196e00afbfc0ddb6b719a30d61e
BLAKE2b-256 checksum
How to use checksums
0141210ff0d476451878385e3f39c095782a791144f5814af0caf13849e44289
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / textual_wasm-0.1.0-py3-none-any.whl

Download URL textual_wasm-0.1.0-py3-none-any.whl
Size 135.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e0926589883affb67a1479ea462b25744b1b93533d04444490c97beb72042a21
BLAKE2b-256 checksum
How to use checksums
e383b5ef68cdb5ce28b4d1aa64462b3e28beb52055cd5682739e0ed8ecfdac41
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.0

2 release files

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page