tempestweb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tempestweb-0.1.0/.gitignore +29 -0
- tempestweb-0.1.0/CHANGELOG.md +50 -0
- tempestweb-0.1.0/PKG-INFO +110 -0
- tempestweb-0.1.0/README.md +83 -0
- tempestweb-0.1.0/client/constants.js +17 -0
- tempestweb-0.1.0/client/dom.js +373 -0
- tempestweb-0.1.0/client/events.js +205 -0
- tempestweb-0.1.0/client/livereload.js +29 -0
- tempestweb-0.1.0/client/native/audio.js +58 -0
- tempestweb-0.1.0/client/native/camera.js +73 -0
- tempestweb-0.1.0/client/native/clipboard.js +46 -0
- tempestweb-0.1.0/client/native/geolocation.js +44 -0
- tempestweb-0.1.0/client/native/http.js +126 -0
- tempestweb-0.1.0/client/native/index.js +158 -0
- tempestweb-0.1.0/client/native/notifications.js +109 -0
- tempestweb-0.1.0/client/native/share.js +48 -0
- tempestweb-0.1.0/client/native/storage.js +103 -0
- tempestweb-0.1.0/client/offline/store.js +316 -0
- tempestweb-0.1.0/client/offline/sync.js +225 -0
- tempestweb-0.1.0/client/push/web-push-client.js +190 -0
- tempestweb-0.1.0/client/pwa/install-prompt.js +153 -0
- tempestweb-0.1.0/client/pwa/manifest.js +253 -0
- tempestweb-0.1.0/client/router.js +67 -0
- tempestweb-0.1.0/client/style.js +359 -0
- tempestweb-0.1.0/client/sw/register.js +159 -0
- tempestweb-0.1.0/client/sw/sw.js +401 -0
- tempestweb-0.1.0/client/tempestweb.js +151 -0
- tempestweb-0.1.0/client/transport-sse.js +158 -0
- tempestweb-0.1.0/client/transport-wasm.js +94 -0
- tempestweb-0.1.0/client/transport-ws.js +154 -0
- tempestweb-0.1.0/client/transport.js +49 -0
- tempestweb-0.1.0/client/virtualize.js +160 -0
- tempestweb-0.1.0/pyproject.toml +104 -0
- tempestweb-0.1.0/tempestweb/__init__.py +7 -0
- tempestweb-0.1.0/tempestweb/_core/__init__.py +48 -0
- tempestweb-0.1.0/tempestweb/cli/__init__.py +83 -0
- tempestweb-0.1.0/tempestweb/cli/commands/__init__.py +43 -0
- tempestweb-0.1.0/tempestweb/cli/commands/build.py +737 -0
- tempestweb-0.1.0/tempestweb/cli/commands/dev.py +200 -0
- tempestweb-0.1.0/tempestweb/cli/commands/new.py +60 -0
- tempestweb-0.1.0/tempestweb/cli/commands/run.py +137 -0
- tempestweb-0.1.0/tempestweb/cli/config.py +95 -0
- tempestweb-0.1.0/tempestweb/cli/loader.py +133 -0
- tempestweb-0.1.0/tempestweb/cli/main.py +261 -0
- tempestweb-0.1.0/tempestweb/cli/scaffold.py +245 -0
- tempestweb-0.1.0/tempestweb/components/__init__.py +44 -0
- tempestweb-0.1.0/tempestweb/components/fields.py +123 -0
- tempestweb-0.1.0/tempestweb/components/forms.py +199 -0
- tempestweb-0.1.0/tempestweb/core/__init__.py +29 -0
- tempestweb-0.1.0/tempestweb/core/constants.py +44 -0
- tempestweb-0.1.0/tempestweb/devserver/__init__.py +80 -0
- tempestweb-0.1.0/tempestweb/devserver/http.py +184 -0
- tempestweb-0.1.0/tempestweb/devserver/reload.py +158 -0
- tempestweb-0.1.0/tempestweb/devserver/watcher.py +164 -0
- tempestweb-0.1.0/tempestweb/native/__init__.py +154 -0
- tempestweb-0.1.0/tempestweb/native/audio.py +74 -0
- tempestweb-0.1.0/tempestweb/native/bridges.py +171 -0
- tempestweb-0.1.0/tempestweb/native/camera.py +77 -0
- tempestweb-0.1.0/tempestweb/native/clipboard.py +54 -0
- tempestweb-0.1.0/tempestweb/native/dispatch.py +265 -0
- tempestweb-0.1.0/tempestweb/native/geolocation.py +62 -0
- tempestweb-0.1.0/tempestweb/native/http.py +307 -0
- tempestweb-0.1.0/tempestweb/native/notifications.py +120 -0
- tempestweb-0.1.0/tempestweb/native/share.py +91 -0
- tempestweb-0.1.0/tempestweb/native/storage.py +108 -0
- tempestweb-0.1.0/tempestweb/observability/__init__.py +111 -0
- tempestweb-0.1.0/tempestweb/observability/auth.py +440 -0
- tempestweb-0.1.0/tempestweb/observability/error_boundary.py +235 -0
- tempestweb-0.1.0/tempestweb/observability/feature_flags.py +360 -0
- tempestweb-0.1.0/tempestweb/observability/logger.py +238 -0
- tempestweb-0.1.0/tempestweb/observability/telemetry.py +303 -0
- tempestweb-0.1.0/tempestweb/pwa/__init__.py +54 -0
- tempestweb-0.1.0/tempestweb/pwa/icons.py +151 -0
- tempestweb-0.1.0/tempestweb/pwa/manifest.py +296 -0
- tempestweb-0.1.0/tempestweb/pwa/pyodide_vendor.py +159 -0
- tempestweb-0.1.0/tempestweb/runtime/__init__.py +46 -0
- tempestweb-0.1.0/tempestweb/runtime/events.py +124 -0
- tempestweb-0.1.0/tempestweb/runtime/serialize.py +257 -0
- tempestweb-0.1.0/tempestweb/runtime/session.py +335 -0
- tempestweb-0.1.0/tempestweb/runtime/wasm.py +395 -0
- tempestweb-0.1.0/tempestweb/runtime/wasm_main.py +166 -0
- tempestweb-0.1.0/tempestweb/server/__init__.py +29 -0
- tempestweb-0.1.0/tempestweb/server/app.py +224 -0
- tempestweb-0.1.0/tempestweb/server/webpush.py +345 -0
- tempestweb-0.1.0/tempestweb/transports/__init__.py +89 -0
- tempestweb-0.1.0/tempestweb/transports/base.py +231 -0
- tempestweb-0.1.0/tempestweb/transports/sse.py +234 -0
- tempestweb-0.1.0/tempestweb/transports/wasm.py +191 -0
- tempestweb-0.1.0/tempestweb/transports/websocket.py +184 -0
- tempestweb-0.1.0/tests/client/dom.test.js +211 -0
- tempestweb-0.1.0/tests/client/events.test.js +180 -0
- tempestweb-0.1.0/tests/client/mount.test.js +180 -0
- tempestweb-0.1.0/tests/client/native.test.js +431 -0
- tempestweb-0.1.0/tests/client/offline-store.test.js +140 -0
- tempestweb-0.1.0/tests/client/offline-sync.test.js +151 -0
- tempestweb-0.1.0/tests/client/pwa-install-prompt.test.js +135 -0
- tempestweb-0.1.0/tests/client/pwa-manifest.test.js +127 -0
- tempestweb-0.1.0/tests/client/router.test.js +96 -0
- tempestweb-0.1.0/tests/client/setup.js +17 -0
- tempestweb-0.1.0/tests/client/smoke.test.js +11 -0
- tempestweb-0.1.0/tests/client/style.test.js +203 -0
- tempestweb-0.1.0/tests/client/sw-register.test.js +163 -0
- tempestweb-0.1.0/tests/client/sw-strategies.test.js +194 -0
- tempestweb-0.1.0/tests/client/transport-sse.test.js +152 -0
- tempestweb-0.1.0/tests/client/transport-wasm.test.js +100 -0
- tempestweb-0.1.0/tests/client/transport-ws.test.js +181 -0
- tempestweb-0.1.0/tests/client/virtualize.test.js +95 -0
- tempestweb-0.1.0/tests/client/web-push-client.test.js +186 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.mypy_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
|
|
12
|
+
# Pyodide / WASM build artifacts
|
|
13
|
+
/site/
|
|
14
|
+
/public/pyodide/
|
|
15
|
+
|
|
16
|
+
# Node (only if a test runner needs it; the client itself ships no node_modules)
|
|
17
|
+
node_modules/
|
|
18
|
+
|
|
19
|
+
# Editor / OS
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# Worktrees live outside the tree; ignore any stray local ones
|
|
25
|
+
/worktrees/
|
|
26
|
+
|
|
27
|
+
# Local session permissions (not shared)
|
|
28
|
+
.claude/settings.local.json
|
|
29
|
+
.playwright-mcp/
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **tempestweb** are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/); this project adheres to semantic
|
|
5
|
+
versioning.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-06-11
|
|
8
|
+
|
|
9
|
+
First public release. Build web apps in typed Python — one declarative tree, a
|
|
10
|
+
pure-JS DOM renderer, two execution modes (WASM in the browser via Pyodide, or a
|
|
11
|
+
FastAPI server over WebSocket/SSE).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Two execution modes, one `view()`.** Mode A (WASM/Pyodide) runs Python in the
|
|
16
|
+
browser; Mode B (server) runs it on FastAPI over WebSocket + SSE. The app never
|
|
17
|
+
names a transport.
|
|
18
|
+
- **`tempestweb` CLI** — `new` (scaffold), `dev` (watch + reload), `build`
|
|
19
|
+
(`--mode wasm|server`), `run`. The wasm build emits a static bundle (Pyodide +
|
|
20
|
+
the `tempest_core`/`tempestweb` payload + `app.py`); the server build emits a
|
|
21
|
+
FastAPI host.
|
|
22
|
+
- **Pure-JS client** (no TypeScript, no framework, no build step): DOM patcher,
|
|
23
|
+
`Style`→CSS, delegated events, the three transports (wasm/ws/sse).
|
|
24
|
+
- **Trilho E parity** (live in Mode A): URL routing (deep links + back/forward +
|
|
25
|
+
pushState), virtualized lists with a proportional scrollbar, overlays
|
|
26
|
+
(dialogs/sheets), CSS transitions, pointer gestures (tap/swipe/long-press),
|
|
27
|
+
real form controls (Input/Checkbox/Image), and a11y (semantics→ARIA) / i18n /
|
|
28
|
+
theme.
|
|
29
|
+
- **Native capabilities** wired in both modes (geolocation, clipboard, http,
|
|
30
|
+
share, camera, audio, storage, notifications) — in-process FFI in Mode A,
|
|
31
|
+
proxied over the transport in Mode B.
|
|
32
|
+
- **PWA layer**: installable manifest + icons + a service worker with an injected
|
|
33
|
+
app-shell precache (offline second load).
|
|
34
|
+
- **Observability**: telemetry, logger, error boundary, feature flags, auth —
|
|
35
|
+
adapter pattern.
|
|
36
|
+
- **`tempestweb.components`**: ready-to-use validated fields (EmailField,
|
|
37
|
+
PasswordField, PhoneField, CPFField, CNPJField, AddressField, TextField) and
|
|
38
|
+
forms (LoginForm, SignupForm).
|
|
39
|
+
- **Bilingual docs** (PT-BR + EN) built with MkDocs Material.
|
|
40
|
+
|
|
41
|
+
### Depends on
|
|
42
|
+
|
|
43
|
+
- [`tempest-core`](https://pypi.org/project/tempest-core/) `>=0.1.0` — the
|
|
44
|
+
renderer-agnostic UI engine (IR/reconciler/state/style/widgets).
|
|
45
|
+
|
|
46
|
+
### Known follow-ups
|
|
47
|
+
|
|
48
|
+
- Mode B `view→URL` (pushState) needs a server→client nav envelope (Mode A is
|
|
49
|
+
bidirectional today).
|
|
50
|
+
- WebPush tab-closed delivery and real camera/geo need on-device verification.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tempestweb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build web apps in typed Python — one tree, a DOM renderer, two execution modes (WASM + server).
|
|
5
|
+
Author-email: Mauricio Benjamin <mauricio.benjamin@reloverelations.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: tempest-core>=0.1.0
|
|
9
|
+
Provides-Extra: cli
|
|
10
|
+
Requires-Dist: watchfiles>=0.21; extra == 'cli'
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
17
|
+
Provides-Extra: docs
|
|
18
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
19
|
+
Requires-Dist: mkdocs-static-i18n>=1.2; extra == 'docs'
|
|
20
|
+
Provides-Extra: server
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'server'
|
|
23
|
+
Requires-Dist: websockets>=12; extra == 'server'
|
|
24
|
+
Provides-Extra: webpush
|
|
25
|
+
Requires-Dist: pywebpush>=1.14; extra == 'webpush'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tempestweb
|
|
29
|
+
|
|
30
|
+
📚 **Documentation:** [Português (Brasil)](https://mauriciobenjamin700.github.io/tempestweb/)
|
|
31
|
+
· [English (US)](https://mauriciobenjamin700.github.io/tempestweb/en/) — bilingual
|
|
32
|
+
docs site (PT-BR default + EN-US), deployed to GitHub Pages.
|
|
33
|
+
|
|
34
|
+
> Build web apps in **typed Python**. One declarative widget tree, a **DOM**
|
|
35
|
+
> renderer, and **two execution modes** that share 100% of the application code:
|
|
36
|
+
> **Mode A (WASM)** runs your Python in the browser via Pyodide; **Mode B
|
|
37
|
+
> (server)** runs it on the server (FastAPI) and talks to a thin JS client over
|
|
38
|
+
> **WebSocket or SSE**. Installable **PWA**, **offline-first** (service worker +
|
|
39
|
+
> IndexedDB), and **WebPush** are first-class — parity with `tempest-react-sdk`.
|
|
40
|
+
|
|
41
|
+
Sister project to [tempestroid](../tempestroid) — same "one tree, multiple
|
|
42
|
+
renderers" architecture. The renderer-agnostic engine (IR, reconciler, state,
|
|
43
|
+
style, widgets) is shared; tempestweb adds a **DOM** leaf renderer (pure
|
|
44
|
+
JavaScript, no framework, no build step, no TypeScript) and two patch transports.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
🚧 Early construction. See the design docs:
|
|
49
|
+
|
|
50
|
+
- [`docs/plan.md`](docs/plan.md) — full design and phase plan.
|
|
51
|
+
- [`docs/roadmap.md`](docs/roadmap.md) — phase checklist.
|
|
52
|
+
- [`docs/arquitetura.md`](docs/arquitetura.md) — architecture.
|
|
53
|
+
- [`docs/contract.md`](docs/contract.md) — the Python↔client wire format.
|
|
54
|
+
- [`docs/agents/MANIFEST.md`](docs/agents/MANIFEST.md) — parallel agent task plan.
|
|
55
|
+
|
|
56
|
+
Want runnable apps? Browse the **[Example Gallery](https://mauriciobenjamin700.github.io/tempestweb/en/examples/)**
|
|
57
|
+
([PT-BR](https://mauriciobenjamin700.github.io/tempestweb/examples/)) — 35 single-concept
|
|
58
|
+
demos (stopwatch, forms, data table, kanban, chat, theming, i18n, canvas, native
|
|
59
|
+
capabilities, observability, PWA/WebPush, and a server-mode walkthrough), each
|
|
60
|
+
running unchanged in both modes.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
view(app) ──build──▶ Node tree (IR) ← shared core (vendored from tempestroid)
|
|
66
|
+
│
|
|
67
|
+
diff
|
|
68
|
+
▼
|
|
69
|
+
[ Patch ] insert / remove / update / reorder / replace
|
|
70
|
+
╱ ╲
|
|
71
|
+
Mode A transport Mode B transport
|
|
72
|
+
(pyodide.ffi) (WebSocket | SSE)
|
|
73
|
+
╲ ╱
|
|
74
|
+
client/ (pure JS): apply patches to the DOM
|
|
75
|
+
+ Style→CSS + event capture ← same code in both modes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The application's `view()` never names a transport — the same `examples/counter/app.py`
|
|
79
|
+
runs under `--mode wasm` and `--mode server` unchanged. Capabilities (`native/`:
|
|
80
|
+
http, audio, share, geolocation, clipboard, storage, camera) are typed awaitables
|
|
81
|
+
with the same Python API in both modes — Mode A calls the Web API in-process, Mode
|
|
82
|
+
B proxies it over a round-trip (see [`docs/contract.md`](docs/contract.md)).
|
|
83
|
+
|
|
84
|
+
## Develop
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv venv && uv pip install -e ".[dev,server,cli]"
|
|
88
|
+
make check # ruff + mypy + pytest + JS (jsdom) tests
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Layout
|
|
92
|
+
|
|
93
|
+
| Path | What |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `tempest-core` (dependency) | Renderer-agnostic engine — IR/reconciler/state/style/widgets (`import tempest_core`), extracted from tempestroid. |
|
|
96
|
+
| `tempestweb/components/` | Ready-to-use fields + forms (EmailField, PasswordField, PhoneField, LoginForm, …). |
|
|
97
|
+
| `tempestweb/transports/` | The one seam between modes (`base.py` Protocol, `wasm.py`, `websocket.py`, `sse.py`). |
|
|
98
|
+
| `tempestweb/server/` | FastAPI + WebSocket/SSE host (Mode B). |
|
|
99
|
+
| `tempestweb/native/` | Web API capability adapters — http, audio, share, geo, clipboard, storage, camera (Track N). |
|
|
100
|
+
| `tempestweb/observability/` | Telemetry, logger, error boundary, feature flags, auth — adapter pattern (Track O). |
|
|
101
|
+
| `tempestweb/pwa/` | Web App Manifest + icon emitter (Track P). |
|
|
102
|
+
| `tempestweb/cli/` | `tempestweb new/dev/build/run`. |
|
|
103
|
+
| `client/` | Pure-JS DOM renderer, Style→CSS, event capture; `pwa/` `sw/` `offline/` `push/` `native/` subdirs. |
|
|
104
|
+
| `tests/fixtures/` | Golden wire-format fixtures derived from the core. |
|
|
105
|
+
|
|
106
|
+
## Conventions
|
|
107
|
+
|
|
108
|
+
Python: double quotes, full typing (mypy `--strict`), Google docstrings in English,
|
|
109
|
+
async-first. Client: **plain JavaScript only** — no TypeScript, no framework, no
|
|
110
|
+
build step. See [`CLAUDE.md`](CLAUDE.md).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# tempestweb
|
|
2
|
+
|
|
3
|
+
📚 **Documentation:** [Português (Brasil)](https://mauriciobenjamin700.github.io/tempestweb/)
|
|
4
|
+
· [English (US)](https://mauriciobenjamin700.github.io/tempestweb/en/) — bilingual
|
|
5
|
+
docs site (PT-BR default + EN-US), deployed to GitHub Pages.
|
|
6
|
+
|
|
7
|
+
> Build web apps in **typed Python**. One declarative widget tree, a **DOM**
|
|
8
|
+
> renderer, and **two execution modes** that share 100% of the application code:
|
|
9
|
+
> **Mode A (WASM)** runs your Python in the browser via Pyodide; **Mode B
|
|
10
|
+
> (server)** runs it on the server (FastAPI) and talks to a thin JS client over
|
|
11
|
+
> **WebSocket or SSE**. Installable **PWA**, **offline-first** (service worker +
|
|
12
|
+
> IndexedDB), and **WebPush** are first-class — parity with `tempest-react-sdk`.
|
|
13
|
+
|
|
14
|
+
Sister project to [tempestroid](../tempestroid) — same "one tree, multiple
|
|
15
|
+
renderers" architecture. The renderer-agnostic engine (IR, reconciler, state,
|
|
16
|
+
style, widgets) is shared; tempestweb adds a **DOM** leaf renderer (pure
|
|
17
|
+
JavaScript, no framework, no build step, no TypeScript) and two patch transports.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
🚧 Early construction. See the design docs:
|
|
22
|
+
|
|
23
|
+
- [`docs/plan.md`](docs/plan.md) — full design and phase plan.
|
|
24
|
+
- [`docs/roadmap.md`](docs/roadmap.md) — phase checklist.
|
|
25
|
+
- [`docs/arquitetura.md`](docs/arquitetura.md) — architecture.
|
|
26
|
+
- [`docs/contract.md`](docs/contract.md) — the Python↔client wire format.
|
|
27
|
+
- [`docs/agents/MANIFEST.md`](docs/agents/MANIFEST.md) — parallel agent task plan.
|
|
28
|
+
|
|
29
|
+
Want runnable apps? Browse the **[Example Gallery](https://mauriciobenjamin700.github.io/tempestweb/en/examples/)**
|
|
30
|
+
([PT-BR](https://mauriciobenjamin700.github.io/tempestweb/examples/)) — 35 single-concept
|
|
31
|
+
demos (stopwatch, forms, data table, kanban, chat, theming, i18n, canvas, native
|
|
32
|
+
capabilities, observability, PWA/WebPush, and a server-mode walkthrough), each
|
|
33
|
+
running unchanged in both modes.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
view(app) ──build──▶ Node tree (IR) ← shared core (vendored from tempestroid)
|
|
39
|
+
│
|
|
40
|
+
diff
|
|
41
|
+
▼
|
|
42
|
+
[ Patch ] insert / remove / update / reorder / replace
|
|
43
|
+
╱ ╲
|
|
44
|
+
Mode A transport Mode B transport
|
|
45
|
+
(pyodide.ffi) (WebSocket | SSE)
|
|
46
|
+
╲ ╱
|
|
47
|
+
client/ (pure JS): apply patches to the DOM
|
|
48
|
+
+ Style→CSS + event capture ← same code in both modes
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The application's `view()` never names a transport — the same `examples/counter/app.py`
|
|
52
|
+
runs under `--mode wasm` and `--mode server` unchanged. Capabilities (`native/`:
|
|
53
|
+
http, audio, share, geolocation, clipboard, storage, camera) are typed awaitables
|
|
54
|
+
with the same Python API in both modes — Mode A calls the Web API in-process, Mode
|
|
55
|
+
B proxies it over a round-trip (see [`docs/contract.md`](docs/contract.md)).
|
|
56
|
+
|
|
57
|
+
## Develop
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv venv && uv pip install -e ".[dev,server,cli]"
|
|
61
|
+
make check # ruff + mypy + pytest + JS (jsdom) tests
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Layout
|
|
65
|
+
|
|
66
|
+
| Path | What |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `tempest-core` (dependency) | Renderer-agnostic engine — IR/reconciler/state/style/widgets (`import tempest_core`), extracted from tempestroid. |
|
|
69
|
+
| `tempestweb/components/` | Ready-to-use fields + forms (EmailField, PasswordField, PhoneField, LoginForm, …). |
|
|
70
|
+
| `tempestweb/transports/` | The one seam between modes (`base.py` Protocol, `wasm.py`, `websocket.py`, `sse.py`). |
|
|
71
|
+
| `tempestweb/server/` | FastAPI + WebSocket/SSE host (Mode B). |
|
|
72
|
+
| `tempestweb/native/` | Web API capability adapters — http, audio, share, geo, clipboard, storage, camera (Track N). |
|
|
73
|
+
| `tempestweb/observability/` | Telemetry, logger, error boundary, feature flags, auth — adapter pattern (Track O). |
|
|
74
|
+
| `tempestweb/pwa/` | Web App Manifest + icon emitter (Track P). |
|
|
75
|
+
| `tempestweb/cli/` | `tempestweb new/dev/build/run`. |
|
|
76
|
+
| `client/` | Pure-JS DOM renderer, Style→CSS, event capture; `pwa/` `sw/` `offline/` `push/` `native/` subdirs. |
|
|
77
|
+
| `tests/fixtures/` | Golden wire-format fixtures derived from the core. |
|
|
78
|
+
|
|
79
|
+
## Conventions
|
|
80
|
+
|
|
81
|
+
Python: double quotes, full typing (mypy `--strict`), Google docstrings in English,
|
|
82
|
+
async-first. Client: **plain JavaScript only** — no TypeScript, no framework, no
|
|
83
|
+
build step. See [`CLAUDE.md`](CLAUDE.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// constants.js — shared client-side constants (tunables used across modules).
|
|
2
|
+
//
|
|
3
|
+
// Module-private values stay in their module; this file holds the few constants
|
|
4
|
+
// that are shared or are worth naming/tuning in one place: gesture-recognition
|
|
5
|
+
// thresholds and the virtualization stylesheet id.
|
|
6
|
+
|
|
7
|
+
/** Minimum pointer travel (px) for a drag to count as a swipe. */
|
|
8
|
+
export const SWIPE_MIN_PX = 30;
|
|
9
|
+
|
|
10
|
+
/** Hold time (ms, with little travel) for a press to count as a long press. */
|
|
11
|
+
export const LONG_PRESS_MS = 500;
|
|
12
|
+
|
|
13
|
+
/** Widget type tag that opts into gesture events (tap/swipe/long_press). */
|
|
14
|
+
export const GESTURE_TYPE = "GestureDetector";
|
|
15
|
+
|
|
16
|
+
/** Id of the injected stylesheet that carries virtualized-list spacer heights. */
|
|
17
|
+
export const VIRT_STYLE_ID = "tw-virt-styles";
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// dom.js — build a DOM tree from the Node IR and apply patch batches to it. W1.
|
|
2
|
+
//
|
|
3
|
+
// buildElement(node) turns one serialized Node into a live DOM element (recursing
|
|
4
|
+
// into children); applyPatches(root, patches) mutates a tree in place. Given the
|
|
5
|
+
// DOM built from node_initial.json, applying patches_all_kinds.json yields the
|
|
6
|
+
// expected DOM. Patch kinds are distinguished by key presence (see transport.js):
|
|
7
|
+
// - set_props present -> Update
|
|
8
|
+
// - node + index present -> Insert
|
|
9
|
+
// - index only -> Remove
|
|
10
|
+
// - order present -> Reorder
|
|
11
|
+
// - node without index -> Replace
|
|
12
|
+
//
|
|
13
|
+
// Every element carries `data-tw-key` when its Node has a key, so events.js can
|
|
14
|
+
// read the originating widget key via event delegation. Verify against
|
|
15
|
+
// ../tests/fixtures/ in tests/client/ (jsdom). No framework.
|
|
16
|
+
|
|
17
|
+
import { styleToCss } from "./style.js";
|
|
18
|
+
|
|
19
|
+
/** Attribute holding a widget's stable reconciliation key. */
|
|
20
|
+
export const KEY_ATTR = "data-tw-key";
|
|
21
|
+
/** Attribute holding a widget's IR type (so patches can re-key/inspect it). */
|
|
22
|
+
export const TYPE_ATTR = "data-tw-type";
|
|
23
|
+
|
|
24
|
+
// Each widget type maps to one HTML tag. Container-like widgets are <div>; Text is
|
|
25
|
+
// an inline <span>; Button is a real <button>. Unknown types fall back to <div> so
|
|
26
|
+
// a new core widget renders (as a generic box) rather than throwing.
|
|
27
|
+
const TAG_BY_TYPE = Object.freeze({
|
|
28
|
+
Column: "div",
|
|
29
|
+
Row: "div",
|
|
30
|
+
Container: "div",
|
|
31
|
+
Stack: "div",
|
|
32
|
+
Text: "span",
|
|
33
|
+
Button: "button",
|
|
34
|
+
Input: "input",
|
|
35
|
+
Checkbox: "input",
|
|
36
|
+
Image: "img",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the HTML tag name for an IR widget type.
|
|
41
|
+
* @param {string} type The widget type ("Column", "Text", "Button", ...).
|
|
42
|
+
* @returns {string} The HTML tag name (defaults to "div").
|
|
43
|
+
*/
|
|
44
|
+
function tagForType(type) {
|
|
45
|
+
return TAG_BY_TYPE[type] ?? "div";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply a node's props to an element: style, key/type attributes and text.
|
|
50
|
+
*
|
|
51
|
+
* `content` (Text) and `label` (Button) become the element's text. The `style`
|
|
52
|
+
* prop is translated by {@link styleToCss} into the inline `style` attribute. The
|
|
53
|
+
* widget `key` and `type` are mirrored onto data attributes for event delegation.
|
|
54
|
+
*
|
|
55
|
+
* @param {HTMLElement} el The target element.
|
|
56
|
+
* @param {string} type The widget type.
|
|
57
|
+
* @param {?string} key The widget key, or null.
|
|
58
|
+
* @param {Object} props The widget props (may include `style`).
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
function applyNodeShape(el, type, key, props) {
|
|
62
|
+
el.setAttribute(TYPE_ATTR, type);
|
|
63
|
+
if (key != null) {
|
|
64
|
+
el.setAttribute(KEY_ATTR, key);
|
|
65
|
+
} else {
|
|
66
|
+
el.removeAttribute(KEY_ATTR);
|
|
67
|
+
}
|
|
68
|
+
applyProps(el, props ?? {});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply a bag of props onto an element (style + text-bearing props).
|
|
73
|
+
*
|
|
74
|
+
* Used both when first building an element and by Update patches. `style` is
|
|
75
|
+
* (re)translated to CSS; `content`/`label` set the text. Other keys are widget
|
|
76
|
+
* metadata the DOM does not render and are ignored.
|
|
77
|
+
*
|
|
78
|
+
* @param {HTMLElement} el The target element.
|
|
79
|
+
* @param {Object} props The props to apply.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
function applyProps(el, props) {
|
|
83
|
+
if ("style" in props) {
|
|
84
|
+
const css = styleToCss(props.style);
|
|
85
|
+
if (css) {
|
|
86
|
+
el.style.cssText = css;
|
|
87
|
+
} else {
|
|
88
|
+
el.removeAttribute("style");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const type = el.getAttribute(TYPE_ATTR);
|
|
92
|
+
// Text-bearing props. A Checkbox is an <input> and cannot hold text, so its
|
|
93
|
+
// label rides as an accessible name instead of textContent.
|
|
94
|
+
if ("content" in props) {
|
|
95
|
+
el.textContent = props.content == null ? "" : String(props.content);
|
|
96
|
+
}
|
|
97
|
+
if ("label" in props) {
|
|
98
|
+
if (type === "Checkbox") {
|
|
99
|
+
el.setAttribute("aria-label", props.label == null ? "" : String(props.label));
|
|
100
|
+
} else {
|
|
101
|
+
el.textContent = props.label == null ? "" : String(props.label);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
applyControlProps(el, type, props);
|
|
105
|
+
applyA11yProps(el, props);
|
|
106
|
+
applyLazyProps(el, type, props);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply accessibility props (semantics + focus) onto an element.
|
|
111
|
+
*
|
|
112
|
+
* Maps the core's renderer-agnostic a11y model to ARIA/DOM: ``semantics.label``
|
|
113
|
+
* → ``aria-label``, ``semantics.role`` → ``role``, ``semantics.hint`` →
|
|
114
|
+
* ``aria-description``; ``focus_order`` sets an explicit ``tabindex`` and
|
|
115
|
+
* ``focusable`` toggles a default one (``0`` to include, ``-1`` to exclude).
|
|
116
|
+
*
|
|
117
|
+
* @param {HTMLElement} el The target element.
|
|
118
|
+
* @param {Object} props The props to apply.
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
function applyA11yProps(el, props) {
|
|
122
|
+
const sem = props.semantics;
|
|
123
|
+
if (sem != null && typeof sem === "object") {
|
|
124
|
+
if (sem.label != null) {
|
|
125
|
+
el.setAttribute("aria-label", String(sem.label));
|
|
126
|
+
}
|
|
127
|
+
if (sem.role != null) {
|
|
128
|
+
el.setAttribute("role", String(sem.role));
|
|
129
|
+
}
|
|
130
|
+
if (sem.hint != null) {
|
|
131
|
+
el.setAttribute("aria-description", String(sem.hint));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (props.focus_order != null) {
|
|
135
|
+
el.setAttribute("tabindex", String(props.focus_order));
|
|
136
|
+
} else if (props.focusable === true) {
|
|
137
|
+
el.setAttribute("tabindex", "0");
|
|
138
|
+
} else if (props.focusable === false) {
|
|
139
|
+
el.setAttribute("tabindex", "-1");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Apply form-control / media props (Input, Checkbox, Image) onto an element.
|
|
145
|
+
*
|
|
146
|
+
* Maps the widget's typed props onto the right DOM property/attribute so the
|
|
147
|
+
* control is actually interactive (a real <input> holding `value`, a checkbox
|
|
148
|
+
* reflecting `checked`, an <img> pointing at `src`). No-ops for other types.
|
|
149
|
+
*
|
|
150
|
+
* @param {HTMLElement} el The target element.
|
|
151
|
+
* @param {?string} type The widget type (from the data-tw-type attribute).
|
|
152
|
+
* @param {Object} props The props to apply.
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
// Virtualized list widgets: rendered as scroll viewports whose visible window
|
|
156
|
+
// the runtime slides in response to scroll events (see client/virtualize.js).
|
|
157
|
+
const LAZY_TYPES = Object.freeze(["LazyColumn", "LazyRow", "LazyGrid"]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Mark a virtualized list element and mirror its windowing metadata to data
|
|
161
|
+
* attributes so the scroll controller can compute the visible window.
|
|
162
|
+
*
|
|
163
|
+
* @param {HTMLElement} el The target element.
|
|
164
|
+
* @param {?string} type The widget type.
|
|
165
|
+
* @param {Object} props The props to apply.
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
function applyLazyProps(el, type, props) {
|
|
169
|
+
if (type == null || !LAZY_TYPES.includes(type)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const horizontal = type === "LazyRow";
|
|
173
|
+
// A bounded, scrollable viewport: the app's Style sets the extent (e.g.
|
|
174
|
+
// height); overflow scrolls the materialized window, and scrolling past the
|
|
175
|
+
// edge slides the window (see client/virtualize.js). min-height:0 stops a flex
|
|
176
|
+
// parent from growing the viewport to fit its content instead of scrolling.
|
|
177
|
+
el.style.overflowY = horizontal ? "hidden" : "auto";
|
|
178
|
+
el.style.overflowX = horizontal ? "auto" : "hidden";
|
|
179
|
+
el.style.minHeight = "0";
|
|
180
|
+
if ("item_count" in props && props.item_count != null) {
|
|
181
|
+
el.setAttribute("data-tw-item-count", String(props.item_count));
|
|
182
|
+
}
|
|
183
|
+
if ("window_size" in props && props.window_size != null) {
|
|
184
|
+
el.setAttribute("data-tw-window-size", String(props.window_size));
|
|
185
|
+
}
|
|
186
|
+
// window is [start, end) when slid, or null (start at 0).
|
|
187
|
+
const start = Array.isArray(props.window) ? props.window[0] : 0;
|
|
188
|
+
el.setAttribute("data-tw-window-start", String(start ?? 0));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function applyControlProps(el, type, props) {
|
|
192
|
+
if (type === "Input") {
|
|
193
|
+
el.setAttribute("type", props.secure ? "password" : "text");
|
|
194
|
+
if ("value" in props) {
|
|
195
|
+
el.value = props.value == null ? "" : String(props.value);
|
|
196
|
+
}
|
|
197
|
+
if ("placeholder" in props && props.placeholder != null) {
|
|
198
|
+
el.setAttribute("placeholder", String(props.placeholder));
|
|
199
|
+
}
|
|
200
|
+
if (props.max_length != null) {
|
|
201
|
+
el.setAttribute("maxlength", String(props.max_length));
|
|
202
|
+
}
|
|
203
|
+
} else if (type === "Checkbox") {
|
|
204
|
+
el.setAttribute("type", "checkbox");
|
|
205
|
+
if ("checked" in props) {
|
|
206
|
+
el.checked = Boolean(props.checked);
|
|
207
|
+
}
|
|
208
|
+
} else if (type === "Image") {
|
|
209
|
+
if ("src" in props && props.src != null) {
|
|
210
|
+
el.setAttribute("src", String(props.src));
|
|
211
|
+
}
|
|
212
|
+
if ("alt" in props && props.alt != null) {
|
|
213
|
+
el.setAttribute("alt", String(props.alt));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build a DOM element from an IR node (recursing into its children).
|
|
220
|
+
* @param {import("./transport.js").Node} node The serialized node.
|
|
221
|
+
* @returns {HTMLElement} The constructed element subtree.
|
|
222
|
+
*/
|
|
223
|
+
export function buildElement(node) {
|
|
224
|
+
const el = document.createElement(tagForType(node.type));
|
|
225
|
+
applyNodeShape(el, node.type, node.key ?? null, node.props ?? {});
|
|
226
|
+
for (const child of node.children ?? []) {
|
|
227
|
+
el.appendChild(buildElement(child));
|
|
228
|
+
}
|
|
229
|
+
return el;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Walk a path of child indices from `root` down to the target element.
|
|
234
|
+
* @param {HTMLElement} root The root element.
|
|
235
|
+
* @param {number[]} path Child indices from the root ([] = root).
|
|
236
|
+
* @returns {HTMLElement} The element at `path`.
|
|
237
|
+
* @throws {RangeError} If an index does not resolve to an element.
|
|
238
|
+
*/
|
|
239
|
+
function resolvePath(root, path) {
|
|
240
|
+
/** @type {HTMLElement} */
|
|
241
|
+
let el = root;
|
|
242
|
+
for (const index of path) {
|
|
243
|
+
const next = el.children[index];
|
|
244
|
+
if (next == null) {
|
|
245
|
+
throw new RangeError(`tempestweb: patch path out of range at index ${index}`);
|
|
246
|
+
}
|
|
247
|
+
el = /** @type {HTMLElement} */ (next);
|
|
248
|
+
}
|
|
249
|
+
return el;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Apply a single Update patch: set/unset props on the node at `path`.
|
|
254
|
+
* @param {HTMLElement} root The root element.
|
|
255
|
+
* @param {{path:number[], set_props?:Object, unset_props?:string[]}} patch The patch.
|
|
256
|
+
* @returns {void}
|
|
257
|
+
*/
|
|
258
|
+
function applyUpdate(root, patch) {
|
|
259
|
+
const el = resolvePath(root, patch.path);
|
|
260
|
+
if (patch.set_props) {
|
|
261
|
+
applyProps(el, patch.set_props);
|
|
262
|
+
}
|
|
263
|
+
for (const key of patch.unset_props ?? []) {
|
|
264
|
+
if (key === "style") {
|
|
265
|
+
el.removeAttribute("style");
|
|
266
|
+
} else if (key === "content" || key === "label") {
|
|
267
|
+
el.textContent = "";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Apply a single Insert patch: insert a new child at `index` under `path`.
|
|
274
|
+
* @param {HTMLElement} root The root element.
|
|
275
|
+
* @param {{path:number[], index:number, node:import("./transport.js").Node}} patch
|
|
276
|
+
* @returns {void}
|
|
277
|
+
*/
|
|
278
|
+
function applyInsert(root, patch) {
|
|
279
|
+
const parent = resolvePath(root, patch.path);
|
|
280
|
+
const child = buildElement(patch.node);
|
|
281
|
+
const ref = parent.children[patch.index] ?? null;
|
|
282
|
+
parent.insertBefore(child, ref);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Apply a single Remove patch: remove the child at `index` under `path`.
|
|
287
|
+
* @param {HTMLElement} root The root element.
|
|
288
|
+
* @param {{path:number[], index:number}} patch The patch.
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*/
|
|
291
|
+
function applyRemove(root, patch) {
|
|
292
|
+
const parent = resolvePath(root, patch.path);
|
|
293
|
+
const child = parent.children[patch.index];
|
|
294
|
+
if (child != null) {
|
|
295
|
+
parent.removeChild(child);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Apply a single Reorder patch: new child `i` = old child `order[i]`.
|
|
301
|
+
*
|
|
302
|
+
* Snapshots the current children first so indices in `order` refer to the
|
|
303
|
+
* pre-reorder positions, then re-appends them in the requested order.
|
|
304
|
+
*
|
|
305
|
+
* @param {HTMLElement} root The root element.
|
|
306
|
+
* @param {{path:number[], order:number[]}} patch The patch.
|
|
307
|
+
* @returns {void}
|
|
308
|
+
*/
|
|
309
|
+
function applyReorder(root, patch) {
|
|
310
|
+
const parent = resolvePath(root, patch.path);
|
|
311
|
+
const before = Array.from(parent.children);
|
|
312
|
+
for (const index of patch.order) {
|
|
313
|
+
const child = before[index];
|
|
314
|
+
if (child != null) {
|
|
315
|
+
parent.appendChild(child);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Apply a single Replace patch: swap the element at `path` for a fresh subtree.
|
|
322
|
+
* @param {HTMLElement} root The root element.
|
|
323
|
+
* @param {{path:number[], node:import("./transport.js").Node}} patch The patch.
|
|
324
|
+
* @returns {void}
|
|
325
|
+
*/
|
|
326
|
+
function applyReplace(root, patch) {
|
|
327
|
+
const old = resolvePath(root, patch.path);
|
|
328
|
+
const fresh = buildElement(patch.node);
|
|
329
|
+
if (old.parentNode) {
|
|
330
|
+
old.parentNode.replaceChild(fresh, old);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Classify a patch by key presence and dispatch it to the right applier.
|
|
336
|
+
* @param {HTMLElement} root The root element.
|
|
337
|
+
* @param {import("./transport.js").Patch} patch The patch to apply.
|
|
338
|
+
* @returns {void}
|
|
339
|
+
* @throws {TypeError} If the patch shape is unrecognized.
|
|
340
|
+
*/
|
|
341
|
+
function applyPatch(root, patch) {
|
|
342
|
+
if ("set_props" in patch || "unset_props" in patch) {
|
|
343
|
+
applyUpdate(root, /** @type {any} */ (patch));
|
|
344
|
+
} else if ("order" in patch) {
|
|
345
|
+
applyReorder(root, /** @type {any} */ (patch));
|
|
346
|
+
} else if ("node" in patch && "index" in patch) {
|
|
347
|
+
applyInsert(root, /** @type {any} */ (patch));
|
|
348
|
+
} else if ("node" in patch) {
|
|
349
|
+
applyReplace(root, /** @type {any} */ (patch));
|
|
350
|
+
} else if ("index" in patch) {
|
|
351
|
+
applyRemove(root, /** @type {any} */ (patch));
|
|
352
|
+
} else {
|
|
353
|
+
throw new TypeError(`tempestweb: unrecognized patch shape ${JSON.stringify(patch)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Apply a coalesced batch of patches to the DOM tree rooted at `root`.
|
|
359
|
+
*
|
|
360
|
+
* The reconciler coalesces a tick's mutations into one ordered list; the whole
|
|
361
|
+
* list is applied before the next frame. Patches are applied in array order — the
|
|
362
|
+
* order the core emitted them — so index-relative ops (insert/remove/reorder)
|
|
363
|
+
* stay consistent.
|
|
364
|
+
*
|
|
365
|
+
* @param {HTMLElement} root The mounted root element.
|
|
366
|
+
* @param {import("./transport.js").Patch[]} patches The tick's patch batch.
|
|
367
|
+
* @returns {void}
|
|
368
|
+
*/
|
|
369
|
+
export function applyPatches(root, patches) {
|
|
370
|
+
for (const patch of patches) {
|
|
371
|
+
applyPatch(root, patch);
|
|
372
|
+
}
|
|
373
|
+
}
|