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.
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.
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). - 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),make_state(),view()with handler closures. - 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
— 356 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. - 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. 🚀