Mode C — transpile (Python → native JavaScript) 🚀¶
What you'll learn
How to turn your Python app.py into a static bundle of native
JavaScript — no Python runtime in the browser — and serve it from any CDN,
with an installable, offline PWA out of the box.
Modes A (WASM) and B (server) keep Python alive at runtime — in the browser (Pyodide) or on the server. Mode C is different: a compiler transcribes the app layer of your typed Python into native JavaScript. Zero Python runtime, static hosting, great first paint and SEO. It's the "TypeScript story" for Python. 🚀
A first-class mode
Mode C is mature and first-class — it covers 100% of tempest_core
widgets, a wide subset of typed Python (see
The supported subset) and a complete PWA story
(installable, offline, mutation queue, end-to-end WebPush). It's the
recommended choice for public sites and PWAs. Only a handful of advanced
constructs sit outside the subset — the compiler fails early, with
file:line, when you hit one.
Why it exists¶
| Mode A (WASM) | Mode B (server) | Mode C (transpile) | |
|---|---|---|---|
| Python runtime | browser (~6 MB Pyodide) | live server | none |
| First paint / SEO | poor | good | great |
| Hosting | static | server + WS/client | static |
| Scale cost | zero server | stateful per client | zero server |
The trick: the JS client (dom.js, style.js, events.js) is already native and
shared by all three modes. Mode C does not transpile all of Python — only the
app layer; the whole renderer stays the same JS.
Your first build¶
Take the counter app (the same one that runs in Modes A/B, unchanged):
from dataclasses import dataclass
from tempest_core import App, Button, Column, Row, Style, Text, Widget
from tempest_core import Edge
@dataclass
class CounterState:
value: int = 0
def make_state() -> CounterState:
return CounterState()
def view(app: App[CounterState]) -> Widget:
def increment() -> None:
app.set_state(lambda s: setattr(s, "value", s.value + 1))
def decrement() -> None:
app.set_state(lambda s: setattr(s, "value", s.value - 1))
return Column(
style=Style(gap=8.0, padding=Edge.all(16)),
children=[
Text(content=f"Count: {app.state.value}", key="label"),
Row(
style=Style(gap=4.0),
children=[
Button(label="-", on_click=decrement, key="dec"),
Button(label="+", on_click=increment, key="inc"),
],
),
],
)
Generate the static bundle:
This writes a fully static dist/transpile/ directory — no Python:
dist/transpile/
├── index.html # mounts the app via mountApp
└── client/
├── tempestweb.js dom.js style.js events.js … # the shared client
└── transpile/
├── app.gen.js # your app.py transcribed to native JS
├── runtime.js widgets.js diff.js
└── widget-styles.gen.js # MD3 styles resolved from the core
Serve it with any static host (or locally):
While developing, use the livereload loop — edit app.py and the browser
reloads with the recompiled bundle:
What happened
Your view() became app.gen.js — native JavaScript. The runtime holds the
state, runs view(), diffs in JS and applies granular patches to the
DOM. No Python is downloaded or executed in the browser.
What the compiler emits¶
The app.py above becomes, in essence:
import { State } from "./runtime.js";
import { Button, Column, Edge, Row, Style, Text } from "./widgets.js";
export class CounterState extends State {
constructor() {
super();
this.value = 0;
}
}
export function makeState() {
return new CounterState();
}
export function view(app) {
const increment = () => {
app.setState((s) => {
s.value = (s.value + 1);
});
};
// …
return Column({
style: Style({ gap: 8.0, padding: Edge.all(16) }),
children: [
Text({ content: `Count: ${app.state.value}`, key: "label" }),
// …
],
});
}
Naming conventions
The compiler translates the API to idiomatic JS: make_state → makeState,
set_state → setState, on_click → onClick, color_scheme →
colorScheme. setattr(s, "x", v) becomes s.x = v; f-strings become
template literals.
State with methods¶
You are not limited to setattr lambdas. A @dataclass with methods transpiles
to a JS class — self becomes this:
@dataclass
class Counter:
value: int = 0
def increment(self) -> None:
self.value += 1
def view(app: App[Counter]) -> Widget:
def inc() -> None:
app.set_state(lambda s: s.increment())
return Button(label="+", on_click=inc, key="inc")
Reactive form fields¶
Input resolves its Material 3 style and wires on_change. Binding is two-way:
typing fires the handler, which updates the state and re-renders.
from tempest_core import App, Column, Style, Text, Widget
from tempest_core import Edge
from tempest_core import Input
@dataclass
class FormState:
name: str = ""
def view(app: App[FormState]) -> Widget:
def on_name(event) -> None:
app.set_state(lambda s: setattr(s, "name", event.payload["value"]))
return Column(
style=Style(gap=12.0, padding=Edge.all(24)),
children=[
Text(content=f"Hello, {app.state.name or 'stranger'}!", key="greet"),
Input(value=app.state.name, placeholder="Your name", on_change=on_name, key="name"),
],
)
Type in the field and the greeting updates live — no server, no Python. ✨
Native capabilities (requests, storage, cookies…)¶
The same typed native API from Modes A/B works in Mode C — async calls are
transcribed to in-process JS calls into the shared browser glue (fetch,
IndexedDB/localStorage, document.cookie). No Python, no network.
The three import forms Python writes all reach the same place:
from tempestweb import native # the namespace
from tempestweb.native import storage, get_position # a group and a function
from tempestweb.native.geolocation import get_position # the group as a module
A capability Mode C does not have, said at build time
camera has no in-process facade — camera.capture needs Mode A (Pyodide)
or Mode B (server). Importing it in Mode C is a compile error with
file:line saying so, rather than a page that loads and breaks on click.
The live list of what the facade serves is generated from
client/transpile/native.js itself.
from tempestweb import native
@dataclass
class DataState:
body: str = ""
def view(app: App[DataState]) -> Widget:
async def fetch_it() -> None:
res = await native.http.request("GET", "/api/items")
await native.storage.put("last", res.body)
await native.cookies.set("seen", "1")
app.set_state(lambda s: setattr(s, "body", res.body))
return Button(label="fetch", on_click=fetch_it, key="go")
async handlers
A handler may be async def and use await. The re-render happens when
set_state runs (after the await), so the UI reflects the result as soon as
the capability resolves. Capabilities available in Mode C: http, storage
(IndexedDB/localStorage), clipboard, geolocation, cookies, share,
audio, file, notifications (incl. WebPush subscribe/unsubscribe),
install (PWA install prompt), offline (durable mutation queue).
Install the PWA (native.install)
await native.install.state() reports {can_install, installed}; after a
user gesture, await native.install.prompt() fires the native install prompt
and resolves with "accepted", "dismissed" or "unavailable". The
controller already suppresses the browser's cold mini-infobar, so you show an
"Install" button at the right moment.
Push (native.notifications)
await native.notifications.push_state() reports {supported, permission}
without prompting — use it to decide whether to show the button. await
native.notifications.request_permission() asks for permission; await
native.notifications.subscribe(vapid_public_key) runs the browser WebPush
flow and returns the subscription JSON — you send it to your own backend
(via native.http, or queued with native.offline). unsubscribe()
cancels. The framework decides neither your endpoint schema nor the push
server: it just hands you the raw subscription.
Offline queue (native.offline)
Writes made offline survive: await native.offline.enqueue("POST", url,
body) records a durable mutation in IndexedDB (with an idempotency key) and
replay happens in FIFO order when connectivity returns — via the online
event, via Background Sync (tab closed) or explicitly with await
native.offline.replay(). Inspect with native.offline.size() and
native.offline.pending(). The server dedups on the idempotency key, so a
replay never double-applies.
Field validators
from tempest_core.validators import validate_email, validate_cpf,
validate_cnpj, validate_phone runs client-side in Mode C, with the same
algorithm and PT-BR messages as the core (a faithful port, locked by a
fixture). Pairs with Input + state for validated, server-free forms.
Navigation (routes + URL)¶
Mode C speaks the same navigation as Modes A/B: app.push(Route(...)),
app.pop(), app.replace(...), app.nav.top — synced with the browser URL
(deep links + back/forward) automatically.
from tempest_core import App, Button, Column, Route, Text, Widget
def view(app: App[MyState]) -> Widget:
def open_product() -> None:
app.push(Route(name="/products/42"))
route = app.nav.top
return Column(children=[
Text(content=f"route: {route.name}", key="r"),
Button(label="open product", on_click=open_product, key="p"),
Button(label="back", on_click=lambda e: app.pop(), key="b"),
])
URL ↔ stack
app.push/pop push/pop the URL (pushState); a deep link or the browser
back button reset the stack from the path (routes_from_path) — identical to
Modes A/B. Path/query params: the route name carries the full path
(including ?query), as the core models it; read segments via
app.nav.stack. A typed-param router is a core-level evolution.
Localization (i18n)¶
The core's translate / t + Locale work in Mode C: look a key up in the
{language: {key: template}} table by the locale's language and interpolate
{name} — same semantics and fallbacks as the core (missing key/language → the
key itself).
from tempest_core import App, Locale, Text, Widget, t
MESSAGES = {
"pt": {"greet": "Olá, {name}!"},
"en": {"greet": "Hello, {name}!"},
}
def view(app: App[MyState]) -> Widget:
loc = Locale(language=app.state.lang)
return Text(content=t("greet", locale=loc, translations=MESSAGES, name="Ana"), key="g")
Flip app.state.lang in a handler and the UI re-renders in the new language —
verified live (Playwright, PT → EN). The MESSAGES table is a module constant
(now supported in the subset).
Theme + responsiveness¶
Mode C exposes app.theme and app.media like Modes A/B.
app.theme.is_dark() resolves light/dark (DARK/LIGHT absolute; SYSTEM
follows the OS); app.media carries width/height/platform_dark_mode/
orientation, synced with the browser (matchMedia + resize) so the UI
re-renders responsively.
from tempest_core import App, Column, Text, Theme, ThemeMode, Widget
def view(app: App[MyState]) -> Widget:
dark = app.theme.is_dark(platform_dark_mode=app.media.platform_dark_mode)
wide = app.media.width >= 600.0
def toggle() -> None:
app.set_theme(Theme(mode=ThemeMode.LIGHT if dark else ThemeMode.DARK))
return Column(children=[
Text(content=("dark" if dark else "light"), key="s"),
Text(content=("wide" if wide else "narrow"), key="l"),
])
Adaptive responsiveness
Resize the window or change the OS prefers-color-scheme and view
re-renders — verified in the browser (400px→narrow, 900px→wide; theme toggle
light↔dark). The core breakpoints (Breakpoints: sm/md/lg/xl) are available
too.
Declare THEME and the bundle delivers it¶
The app's palette is declared once, as a module constant next to view — the
same contract Modes A and B read:
# app.py
from tempest_core import App, Theme, ThemeMode, Widget
THEME: Theme = Theme(mode=ThemeMode.DARK)
def view(app: App[MyState]) -> Widget:
"""No widget here has to be handed `theme=`."""
...
The compiler transcribes THEME into the generated module, the shell hands the
whole module to mountApp, and the runtime does the two things a theme has to do:
- it installs the theme around every build, so each widget is born with the
app's palette without
viewpassing it down — the port of thedefault_factory=current_themethe core declares on every widget; - it marks
data-tw-theme="dark"on the document, which is what the base stylesheet reads for the page background, a field's surface and every hover/focus state.
Until 0.135.0 Mode C ignored THEME
The compiler transcribed the constant and nothing read it. The same screen
came out legible in Mode B and unreadable in C — measured on
examples/router-drawer: a breadcrumb at 1.20:1 and a section title at
1.02:1 (text the exact colour of its background). A first light is still
deliberately not marked: the base sheet's own tokens are the light palette,
and an app that declares no THEME comes out byte for byte as before.
The parity is pinned by test
tests/fixtures/transpile_theme_samples.json is generated from the real core
and carries, per case, the attribute and the whole IR. Modes A and B are
held to it in tests/conformance/test_theme_parity.py; Mode C in
tests/client/transpile-theme.test.js. Three readers, one golden.
Mode C carries the mode, not the palette¶
Mode C's Theme has a single field: mode. Light, dark and SYSTEM work
exactly as in Modes A and B. A custom palette does not: Mode C's style tables are
generated from the default token set, with a light/dark axis and no palette
axis, so a custom colour has nowhere to land.
So the compiler refuses, with file:line, any theme that brings a palette:
| Written | Mode C |
|---|---|
Theme(mode=ThemeMode.DARK) |
✅ compiles |
Theme(primary=...), Theme(surface=...) and the other colour overrides |
❌ refused |
Theme(tokens=...) |
❌ refused |
Theme.from_seed(...) |
❌ refused |
`Theme(primary=...)` is not available in Mode C: the client's `Theme` carries
only `mode`, and its style tables are generated from the default token set — a
custom palette would compile and render the baseline one
Until 0.138.0 Theme(primary=...) compiled and lied
from_seed was already refused, but a Theme(...) with a colour override got
through the kwarg check — the core declares the field — and the JS
constructor, which reads mode alone, dropped the rest. The page came out in
the baseline palette with no warning. Need a custom palette? Use Mode A or B,
where the colour is resolved by Python with the live token set in hand (#221).
Animation (transitions)¶
Animate declaratively: give a widget's Style a Transition and the browser
tweens it when a styled field changes (width, color, opacity) — no Python
runtime, no frame driver.
from tempest_core import App, Container, Style, Widget
from tempest_core import Color, Curve, Transition
def view(app: App[MyState]) -> Widget:
w = 320.0 if app.state.big else 120.0
return Container(key="box", style=Style(
width=w,
background=Color(r=103, g=80, b=164, a=1.0),
transition=Transition(duration_ms=400, curve=Curve.EASE_IN_OUT),
))
Verified
Flipping app.state.big in a handler animates the width 120→320px over 400ms
(Playwright confirmed the CSS transition is applied). Curves: linear,
ease, ease-in, ease-out, ease-in-out, bounce, elastic.
Imperative animation (AnimationController)¶
For frame-driven control, use AnimationController + Tween — the runtime drives
the controllers on a requestAnimationFrame loop, computing the value each frame
and re-rendering.
from tempest_core import AnimationController, Tween
from tempest_core import Curve
def make_state() -> S:
s = S()
s.anim = AnimationController(0.6, curve=Curve.EASE_OUT)
return s
def view(app: App[S]) -> Widget:
w = Tween(begin=100.0, end=340.0).at(app.state.anim.value)
def go() -> None:
app.state.anim.forward()
app.register_animation(app.state.anim)
return Container(key="box", style=Style(width=w))
forward()/reverse()/stop(), eased curves and springs (Spring) — the
same math as the core. Verified in the browser: the width animates 100→340
(ease-out) and settles. This closes 100% of tempest-core coverage in Mode C.
The complete tour¶
Everything above — state with methods, navigation, i18n, theme + responsiveness,
a validated form and an imperative animation — lives together in one reference
app, examples/transpile-tour:
tempestweb build --mode transpile --path examples/transpile-tour
tempestweb dev --mode transpile --path examples/transpile-tour # livereload
One view, every mode
The tour's view() runs unchanged in Modes A and B. build proves it by
rendering through the real core — an API that only existed in Mode C would
break the build, so the tour is living proof of portability.
PWA: installable and offline¶
Start a PWA in one command
Scaffolds a Mode C project already configured (mode = "transpile" + a
[pwa] block) with a counter and an Install button — ready for
tempestweb build --mode transpile.
You already have a 100% static, Python-free bundle — the perfect target for a
PWA. That's why build --mode transpile now emits the whole PWA layer by
itself: users can install your app to their home screen and, after the first
visit, open it offline. No extra step, nothing to wire up. 🚀
Just the usual build:
Alongside the app bundle, Mode C now writes the PWA layer next to it:
dist/transpile/
├── index.html # links the manifest, theme-color, apple-touch-icon
│ # and registers the service worker
├── manifest.webmanifest # install metadata (name, icons, colors)
├── sw.js # cache-first service worker (app shell)
├── register.js # registers sw.js on load
├── icons/ # the icon set (maskable + apple-touch)
└── client/ … # the shared client + your app.gen.js
sw.js precaches the entire static bundle — index.html, the shared client,
client/transpile/* (including your app.gen.js), the native tree, the icons and
the manifest. After the first load, the app opens and runs with no network.
Real offline ✅
This isn't half-baked offline: with the HTTP server killed, reloading the page still renders the tour and navigation keeps working — verified live with Playwright (server down, reload, tour intact). Because Mode C is a static, Python-free bundle, nothing depends on the server after the first fetch.
Configuring the manifest with [pwa]¶
Install metadata comes from an optional [pwa] section in your tempestweb.toml.
Every field is optional — without the section, the build uses sensible defaults
derived from the project name:
| Field | Type | Default | What it does |
|---|---|---|---|
enabled |
boolean | true |
The default manifest and service_worker fall back to. |
manifest |
boolean | enabled |
Emits manifest.webmanifest and its <link>. |
service_worker |
boolean | enabled |
Emits and registers the cache-first worker. |
name |
string | project name | Full name shown at install/splash. |
short_name |
string | — | Short name for the home-screen icon. |
description |
string | — | App description in the install prompt. |
theme_color |
string | "#111111" |
Theme color (browser bar + <meta name="theme-color">). |
background_color |
string | "#ffffff" |
Splash-screen background color. |
display |
string | "standalone" |
Display mode: standalone, fullscreen, or minimal-ui. |
orientation |
string | — | Preferred orientation (e.g. portrait, landscape). |
lang |
string | "pt-BR" |
Primary app language. |
categories |
list of string | — | App-store categories (e.g. ["productivity"]). |
Valid display value
display accepts only "standalone", "fullscreen", or "minimal-ui". Any
other value is a build error — it fails early, in the spirit of the rest of
the Mode C compiler.
Automatic in Mode C
You don't have to hand-write a service worker, manifest, or registration code:
build --mode transpile generates all of it. The [pwa] section tunes
the install metadata — offline behavior comes for free because the bundle is
static.
An app that does not want a PWA
enabled = false turns the whole layer off; manifest and service_worker
turn off one half. Turning the worker off emits a teardown worker in place of
the caching one, so whoever already registered one is not stuck on the old
precache — the why is in
PWA and offline.
Update prompt
When you ship a new version the old service worker keeps serving until the tab closes. The shell detects the waiting worker and shows an unobtrusive "new version available → Reload" banner; on confirm the new worker takes over and the page reloads once. Automatic — nothing to write in the app.
Many modules 📦¶
Your app does not have to fit in one file. The compiler follows the
entrypoint's imports, transpiles every project module it reaches, and emits
the import lines that wire them together.
my-app/
├── tempestweb.toml
├── app.py # entrypoint
└── shop/
├── __init__.py
├── models.py
└── labels.py
from dataclasses import dataclass
@dataclass
class Product:
"""One catalogue item."""
name: str
price: float
from shop.labels import TITLE as TITLE
from shop.models import Product as Product
__all__ = ["Product", "TITLE"]
from dataclasses import dataclass, field
from tempest_core import App, Column, Text, Widget
from shop import TITLE, Product
@dataclass
class State:
"""Storefront state."""
items: list[Product] = field(default_factory=list)
def make_state() -> State:
"""Build the initial state."""
return State(items=[Product(name="Coffee", price=21.9)])
def view(app: App[State]) -> Widget:
"""Render the storefront."""
rows = [Text(content=item.name) for item in app.state.items]
return Column(children=[Text(content=TITLE), *rows])
tempestweb build --mode transpile emits one file per module, all side by side
in client/transpile/, named after the dotted path:
client/transpile/app.gen.js
client/transpile/shop.gen.js
client/transpile/shop.models.gen.js
client/transpile/shop.labels.gen.js
And the entrypoint loads what it uses:
import { State } from "./runtime.js";
import { Column, Text } from "./widgets.js";
import { Product, TITLE } from "./shop.gen.js";
All of them go into the service worker's precache, so the app still opens offline after the first load.
Re-export is explicit
An __init__.py references nothing it imports — it passes names on. The
emitter recognises the two marks Python uses for that: from x import Y as Y
(the redundant alias) and a listing in __all__. Without one of them the
name is imported for internal use and does not leave the module.
Why the dotted name
Every emitted module lives in the same directory, so ./shop.models.gen.js
works from any depth — there is no ../.. to get wrong. As a consequence a
module of yours named widgets, values or spacing would collide with a
file the client already ships; the build refuses with file:line and asks
for another name.
A cycle is refused
a.py importing b.py which imports a.py stops the build. The ES
modules would load, but a name read while the other half is still
evaluating throws on a temporal dead zone, at a line neither of them names.
Break the cycle by moving what both share into a third module.
This is what unlocks your API's typed client
tempestweb gen api writes a package — api/tasks/schemas.py,
api/tasks/service.py, api/tasks/__init__.py. Before Mode C followed
local imports that client simply did not compile here. Now
from api.tasks import TasksService works: see
The typed API client.
The typed API client 🔌¶
A Mode C app runs in the browser: it opens no database session and calls no
service — it speaks HTTP to your backend. tempestweb gen api reads the
openapi.json and writes that client in typed Python, so the types never
drift from the server.
You get one package per route tag:
api/
├── __init__.py
├── _runtime.py
└── tasks/
├── __init__.py
├── schemas.py # one @dataclass per component, from_dict / to_dict
└── service.py # TasksService, one method per route
And the app consumes it like any other local module:
from dataclasses import dataclass, field
from tempest_core import App, Button, Column, Text, Widget
from api.tasks import Task, TaskCreate, TasksService
SERVICE = TasksService()
@dataclass
class State:
"""List state."""
tasks: list[Task] = field(default_factory=list)
def make_state() -> State:
"""Build the initial state."""
return State()
def view(app: App[State]) -> Widget:
"""Render the list."""
async def load() -> None:
rows = await SERVICE.list_tasks()
app.set_state(lambda s: setattr(s, "tasks", rows))
async def add() -> None:
await SERVICE.create_task(TaskCreate(title="new"))
await load()
return Column(
children=[
Button(label="Load", on_click=load),
Button(label="Add", on_click=add),
*[Text(content=t.title) for t in app.state.tasks],
]
)
The service is a @dataclass, so base_url and headers are fields:
SERVICE = TasksService(base_url="https://api.example.com")
AUTHENTICATED = TasksService(headers={"Authorization": f"Bearer {token}"})
Serve both from the same origin
Build the artifact into your backend and mount it at the root, and an empty
base_url already points at your API — no CORS, no preflight, no
environment variable holding the URL. Mount the static files last: a
root mount registered before the routers swallows every later route.
headers is fixed at construction
It is sent with every request, but nothing renews a token that expires. For now, build a new service when the token changes.
What ApiError carries under Mode C
A non-2xx response raises ApiError. In Mode C a raise becomes a JS
Error carrying only the message and the class name, so
except ApiError works and the message reads HTTP <status>: <body>; the
.status and .body attributes exist under Modes A and B only.
The supported subset¶
Mode C accepts a typed subset of Python — enough for the app layer. A
construct outside it becomes a clear compile error (file:line), in the
spirit of mypy --strict.
In the subset today
- Expressions: arithmetic (
+ - * / % ** //), comparison (== != < <= > >=, chaineda < b < c), boolean (and/or), unary (not/-), ternary (a if c else b), list and dict comprehensions (including a tuple targetfor k, v in …),list/tuple/set/dictliterals,in/not in, indexing and slices (x[a:b]), f-strings (formats{x:.2f},{x:,},{x:,.2f},{x:.1%},{x:d}; conversions{x!s},{x!r}), expression lambdas. - Builtins:
len,str/int/float/bool,abs,round(x[, n]),min/max(variadic or over one iterable),sum(it),range(...),enumerate(it),zip(a, b). dataclasses:dataclassandfield. The helpers (asdict,astuple,replace,fields,is_dataclass) have no counterpart in the emitted JS and are refused at build time, saying what to write instead.- Stdlib methods: string/list (
.upper/.lower/.strip/.startswith/.endswith/.append), dict views (.items/.keys/.values→Object.entries/keys/values),sep.join(it). Methods on runtime objects (app.replace,native.storage.get,ctrl.forward) pass through untouched — use subscriptd[k]instead ofdict.get. - Statements:
if/elif/else,for … in(with a tuple target),while,break/continue,try/except/finally(a singleexceptcatches all; multiple dispatch by exception class name),with … as x(the__enter__/__exit__protocol),raise Exc("msg")/raise(re-raise insideexcept),assert cond[, msg], assignment (including unpackinga, b = pairand chaineda = b = x),+=and friends,return. - Structures: a state
@dataclass(fields + methods), dataclass inheritance (class B(A)→extends),@classmethodand@staticmethod(both becomestatic; inside a classmethodclsstill constructs),class Failure(Exception)(it declares only the name —raise/exceptmatch by name, so nothing is emitted for it),make_state(),view()with handler closures. - Project imports:
from my_module import X, relative (from .models import X) and through a package included. Every module reached becomes a sibling.gen.js— see Many modules. - Layout components:
HStack/VStack(SwiftUI-style ergonomic aliases) —gapas a token ("md") or px,align/justifydirect. - Widgets: all ~64
tempest_corewidgets — layout (Column,Row,Container,Stack,Wrap,ScrollView,SafeArea,Spacer), display (Text,Icon,Image,Svg,Spinner,Skeleton,ProgressBar), input (Button,Input,TextArea,Switch,Checkbox,Slider,RangeSlider,Dropdown,DatePicker, …), overlays (Dialog,BottomSheet,Popover,Toast,Tooltip), gestures (GestureDetector,Draggable,PanHandler, …), and more. The JS builders are generated by introspecting the core (widgets.gen.js), with the resolved MD3 style for the 14 styled widgets.
Per-widget events
Each handler binds to the DOM event the renderer (dom.js) emits for that
widget: Button.on_click → click; Input/Checkbox (native controls) →
input/change; a Switch (a div) → click. Handlers for widgets whose
event the client does not yet emit (e.g. on_scan, on_reorder) are
registered but inert for now.
Widget kwargs are validated at build time
Mode C has no Python at runtime: the generated builder destructures the
object it receives and ignores any key it does not name. So the compiler
checks every core-model call against the real fields and fails with
file:line — Container(children=[...]) would be an empty box here and a
ValidationError in Modes A/B. The child slot uses the core's own name:
child on Container/Draggable, children on Column/Row, fields on
Form.
Dark mode does not reach widgets or components in Mode C
The generated style tables carry no mode axis, so the resolved style that travels inline is always the light one — and inline beats the base sheet. Switching the theme changes what the sheet paints, not what the widget carries. #106 weighs the options.
The structural components are ported
All 42 structural components of the core run in Mode C:
- surface and structure:
Surface,StyledContainer,Card,Scaffold,Grid,Sidebar,Drawer,Divider, plus theHStack/VStackaliases; - bars and navigation:
AppBar,Header,Footer,NavBar,Breadcrumb,Burger,SegmentedControl; - content:
ListTile,Avatar,Chip,Tag,Rating,Stepper,SearchBar,RadioGroup; - disclosure and panel selection:
Accordion(the body is only in the tree whileopen, so closing is a remove, not a hide) andTabs(the active tab takes its underline from aSideBorder, with no new style field); - feedback:
Banner,Alert,Badge,EmptyState,Stat,ProgressStepper; - Brazilian fields:
EmailInput,PasswordInput,PhoneInput,CPFInput,CNPJInputandAddressInput— each is the muted label, theInput/MaskedInputwith the right mask and the error line, andon_changereceives the new string, not the event; - composition:
MetricCard,StatCard,ConfidenceBadge— plus the pureconfidence_schemefunction, which is how an app picks the badge's scheme.
Each composition was rewritten in components.js and the output of the
core's style resolvers travels in a generated table
(component-styles.gen.js), the same way widget-styles.gen.js does for the
widgets. Every builder is pinned by a matrix of props built from the real core
— 360 cases — so a drift in composition or resolved style fails the test.
That is 151 components, each with a __dark twin (the mode axis
#106 brought:
a port that forgets to pass the theme down to a child fails on that child's
colour), plus thirty-four __keyed twins: the same component built with
an explicit key=, because the unkeyed build hides the very derivation
(Accordion() emits accordion-header whether or not the builder derives it,
while Accordion(key="faq-3") emits faq-3-header only when it really does).
The comparison now also looks at every descendant key — it used to check
shape and style alone, which is how a port with literal child keys survived a
whole release.
And tempestweb's own components (tempestweb.components) too:
TextField, EmailField, PasswordField, the ready-made LoginForm and
SignupForm, and the PhoneField/CPFField/CNPJField/AddressField
aliases over the core's fields. They derive every child key from the
component's own key, so two of them on one screen do not fight over the name
of the Input that emits the event — and Mode C carries that derivation.
examples/mode-c-components exercises the whole batch in one app.
A component carries the base props — here too
Every widget declares semantics, focusable, focus_order, tag and
attrs. In Modes A and B the core's build carries them onto the root the
component rendered (tempest-core 0.17.0). In Mode C a component is a
function, not a node someone expands: a prop the builder does not read would
reach no node at all, and a screen that is accessible in the browser would go
mute in the transpiled build of itself.
Every builder in components.js now carries them, with the core's rule: the
render owns what it touched. A prop the built tree already sets on any node
is left alone — which is what keeps a field correct, because it puts the
accessible name on the <input> a screen reader stops at, and a second copy
on the role-less wrapper would announce the same control twice.
In all three modes that Card comes out as a <section> announcing "Totals".
Pinned by six __named twins in the parity matrix (both branches of the
rule) and by a sweep over every builder in
tests/client/component-carry.test.js.
The forms a real app writes
The subset takes what a real app writes, not just the counter's minimum:
- annotation-only imports:
from collections.abc import Callableandfrom typing import Anypass and cost no JS import — but using the name as a value is an error ('Any' is a type-only name). A module-level type alias (Fetcher = Callable[[], None]) is dropped too. from tempestweb.components import …: the import the components tutorial teaches, routed to the same served names. A name the client lacks is refused by name, not by module.[a, *rest](the "new list without mutating" idiom), destructured targets (for i, (q, a) in enumerate(pairs)),is/is not(againstNoneit emits== null, the right answer for a field never assigned) andf"{n:02d}"(clock and scoreboard zero-padding, with the sign kept outside the padding the way Python does it).- dataclasses as written: a field with no default (
undefineduntilmake_statefills it),@dataclass(frozen=True), andfield(default_factory=…)with your own callable. - container conversions:
list(xs),tuple(xs),set(xs),dict(pairs). - the stdlib modules the browser has:
re,json,math,base64andasyncio, in either import form (import re/from math import ceil).Pattern.matchanchors at the start the way Python does,re.subreplaces every occurrence, andasyncio.sleep(0.4)waits 400 ms. A member outside the table is refused by name (re.escape), and a module outside the list says what to do instead. - your own
enum:class Phase(StrEnum)becomes a frozen object, the way the core's own enums already travel invalues.gen.js. - generator expressions (
any(x for x in xs)),any/all,dict.getwith a default, and thestrpredicates (c.isdigit()). {**old, k: v}, the "new dict without mutating" idiom (sibling of[a, *rest]), andxs[:] = [...], the in-place replace — which becomes asplice, not an assignment to a copy.- a dict as a dict:
dict(other)copies anddict(pairs)builds — the same call in Python, different operations in JS, told apart at runtime. Andd.pop(key, default)really removes, instead of falling through to the arraypop. - Python's truthiness, not JS's:
"",0,NoneandFalseagree between the languages; an empty container does not —[]and{}are falsy in Python and truthy in JS. A boolean position (if,elif,while,not, the ternary) goes throughtruthy$, soif s.errors:answers what Python would. A comparison, anot, a boolean literal and a name the module only ever binds to a boolean stay unwrapped, so the test stays readable.len(d)counts keys and"k" in dreads a key, instead of falling through to the array.lengthand.includes.
and/or in a value position are left alone
name or "—" returns an operand in both languages, not a boolean, so
|| is already the right behaviour. The difference shows only when the
left operand is an empty container — [] or x gives x in Python and
[] in JS. No example in the corpus writes that, and wrapping would
change what every or evaluates to for a case nobody uses.
- case predicates:
c.isupper()/c.islower()require at least one cased character, as Python does —"1".isupper()isFalse. The classes are ASCII, like the other predicates'. Form.validate(values)— the one widget method Mode C ports. It fits because its input survives:validatorsnever crosses a wire in Mode C, so the live functions are on the node when validation runs. Every other method stays refused.f"{x:+.1f}":+forces the sign on a positive, the way Python does. The value is formatted first and the prefix decided from the result, or a negative would come out+-3.0. It composes with,,%andd; with0Ndit is refused, because Python counts the sign inside the width.if __name__ == "__main__":is skipped, not refused: the block is a script guard and never runs when the file is imported as a module — which is exactly how Mode C compiles it. Anelseon it is still refused, because that one does run.-
a core event constructor:
ThemeChangeEvent(mode=ThemeMode.DARK)and the other 32 events are generated intovalues.gen.js. An app builds one when it simulates a host event. -
native capabilities, in all three import forms:
from tempestweb import native,from tempestweb.native import storageandfrom tempestweb.native.geolocation import get_positionall land on the same object from./native.js. A group the facade does not carry (camera) is refused saying which mode has it, and an unknown member is refused by name (geolocation.triangulate). -
virtualized lists:
LazyColumn,LazyRowandLazyGridmaterialize the visible window by callingitem_builder(index), each item re-keyed by its absolute index — which is what turns a window slide into a minimal remove/reorder/insert instead of a rebuild. Ascrollevent slides the window in the runtime, the way the server does in Mode B, and it survives theviewre-running.
Measured on the corpus: 44 of 57 examples transpile, up from 14.
Always give a component an explicit key
A component's default key is its own name (card, alert, navbar), so two
Cards under the same parent both answer to card and a patch addresses the
wrong one. True in all three modes; Mode C is no different.
The core's enums, value objects and tokens are served
TextAlign.CENTER, FontWeight.BOLD, KeyboardType.EMAIL,
Semantics(label=…), Border, Shadow, Gradient, ACCENT, ON_SURFACE,
HOVER_OPACITY — the core's 32 enums, its non-widget value objects and its
design tokens are generated into values.gen.js from the core, in the
wire shape. Style/Color/Edge stay where they always were
(widget-support.js).
Still outside the subset — and now it fails the build
A core widget's methods (form.validate(values), and any other): the
client ports each widget's builder, not the Python methods of its class.
Calling one is a compile error with file:line, not a page that loads and
dies on the first render.
The data-driven components of tempest_core.components (DataTable,
Table, BarChart/LineChart, DetectionOverlay, ResultView,
Calendar/Clock, the media and form pickers, and CollapsingAppBar, which
depends on the scroll): their tree shape depends on the data they are
handed — one row of cells per record, one bar per datum — so there is no
fixed composition to port. Looping over a flat list of labels is not that:
Tabs and Accordion are fixed compositions and are served, like the
structural ones above
(#107 tracks
what is left). Also out:
comprehensions with more than one for, and f-string format specs beyond
the supported set (e.g. alignment {x:>5}, sign {x:+.2f}, hex/bin {x:x},
dynamic {x:.{n}f}, the !a conversion).
Using one of those names is now a compile error with file:line:
The compiler used to emit import { Card } from "./widgets.js" anyway — an
import the browser cannot resolve, so the module was never evaluated and the
page stayed blank, with nothing in the build log. Importing a type purely
for an annotation (DragEvent, TextChangeHandler) is still free:
annotations are dropped, the name is never referenced, no import is emitted.
Recap¶
- Mode C transcribes the Python app layer to native JS — zero Python runtime, a static bundle, great first paint/SEO.
tempestweb build --mode transpileproduces a directory servable by any CDN;run --mode transpileserves it locally.- The same
view()from Modes A/B runs here — state, handlers, styledButton/Input, reactive binding, navigation, i18n, theming, animation. - The app does not have to fit in one file: the compiler follows your
imports and emits one
.gen.jsper project module. tempestweb gen apiwrites your backend's typed client, and the app imports it like any other module — that is how a FastAPI + Mode C monolith talks to itself, typed at both ends.- It's a mature, first-class mode: 100% of core widgets, a wide typed-Python
subset, and a turnkey PWA. Design details in
docs/modo-c-transpile.md.
Head to PWA & offline for the full install and WebPush flow, or return to the Tutorial to review the counter in the other modes. 🚀