Skip to main content

textual-wasm

pypi 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 five 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. --storage for a persistent filesystem. Refuses a closure Pyodide cannot install.
textual-wasm dev dist/ Serve it locally. Standard library only.
textual-wasm check --app <module:App> Run it on every runtime available and compare.
textual-wasm channels <module> TypeScript declarations for the app's data channels, so the page type-checks against them. --check gates the committed copy.

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.
  • The data channel — live values between the app and the page in both directions, what it costs, and where it stops.
  • 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.
  • Managing persistent storage — why localStorage is the wrong answer (a Web Worker does not have it) and why you need no storage abstraction at all.
  • Library support — 37 Textual add-ons installed into a real Pyodide and mounted; 23 can be shipped today.
  • 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.
page-bridge HTML sliders and Textual meters over the same live values, each moving the other, with the page type-checked against the app.
svelte-app Mounted in a Svelte 5 component, with Svelte state around it.
persistent-notes A SQLite database that survives a page reload. One code path, one extra flush().
addon-gallery Four third-party Textual libraries, none of which knows it is in a browser.

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)
bridge.py      a two-way data channel: text on named channels, JSON on top, bind on top of that
channels.py    what a channel carries, declared once
typegen.py     that declaration as TypeScript, so the page is checked against the app
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.2.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.2.0
File Size Uploaded
textual_wasm-0.2.0.tar.gz 162.5 kB Details

Built distribution (wheel)

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

Total release size: 360.1 kB

Release files / textual_wasm-0.2.0.tar.gz

Download URL textual_wasm-0.2.0.tar.gz
Size 162.5 kB
Tags Source
SHA-256 checksum
How to use checksums
50cc8c48a9b0fd301a4e7ea9c435217f6fc355e6063e98cb2c8e187526a111b1
BLAKE2b-256 checksum
How to use checksums
8fcfe2ce284390e3e055ef426aa4f44f8823d465cd0281df6ced66a2000f9b80
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 17, 2026.

Transparency log

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

Download URL textual_wasm-0.2.0-py3-none-any.whl
Size 197.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f90c2b7529f868cb86ea1245b0760dab76bad9ef4383608f9971cd038d65376d
BLAKE2b-256 checksum
How to use checksums
9f64280400b15c9183023d7fb27c3bea464930969946e0027283b85f3d555358
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 17, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.1

2 release files

0.1.0

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