Skip to content

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:

tempestweb build --mode transpile --path examples/counter

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):

tempestweb dev --mode transpile --path examples/counter

While developing, use the livereload loop — edit app.py and the browser reloads with the recompiled bundle:

tempestweb dev --mode transpile --path examples/counter

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_statemakeState, set_statesetState, on_clickonClick, color_schemecolorScheme. 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.

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

tempestweb new myapp --template pwa
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:

tempestweb build --mode transpile --path examples/transpile-tour

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 bundleindex.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:

[pwa]
name = "Weather Pro"
short_name = "WPro"
theme_color = "#0a84ff"
display = "standalone"
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 (== != < <= > >=, chained a < b < c), boolean (and/or), unary (not/-), ternary (a if c else b), list and dict comprehensions (including a tuple target for k, v in …), list/tuple/set/dict literals, 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/.valuesObject.entries/keys/values), sep.join(it). Methods on runtime objects (app.replace, native.storage.get, ctrl.forward) pass through untouched — use subscript d[k] instead of dict.get.
  • Statements: if/elif/else, for … in (with a tuple target), while, break/continue, try/except/finally (a single except catches all; multiple dispatch by exception class name), with … as x (the __enter__/__exit__ protocol), raise Exc("msg") / raise (re-raise inside except), assert cond[, msg], assignment (including unpacking a, b = pair and chained a = 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) — gap as a token ("md") or px, align/justify direct.
  • Widgets: all ~64 tempest_core widgets — 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:lineContainer(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 the HStack/VStack aliases;
  • 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 while open, so closing is a remove, not a hide) and Tabs (the active tab takes its underline from a SideBorder, with no new style field);
  • feedback: Banner, Alert, Badge, EmptyState, Stat, ProgressStepper;
  • Brazilian fields: EmailInput, PasswordInput, PhoneInput, CPFInput, CNPJInput and AddressInput — each is the muted label, the Input/MaskedInput with the right mask and the error line, and on_change receives the new string, not the event;
  • composition: MetricCard, StatCard, ConfidenceBadge — plus the pure confidence_scheme function, 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.

Card(semantics=Semantics(label="Totals"), tag="section", children=[total])

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 Callable and from typing import Any pass 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 (against None it emits == null, the right answer for a field never assigned) and f"{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 (undefined until make_state fills it), @dataclass(frozen=True), and field(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, base64 and asyncio, in either import form (import re / from math import ceil). Pattern.match anchors at the start the way Python does, re.sub replaces every occurrence, and asyncio.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 in values.gen.js.
  • generator expressions (any(x for x in xs)), any/all, dict.get with a default, and the str predicates (c.isdigit()).
  • {**old, k: v}, the "new dict without mutating" idiom (sibling of [a, *rest]), and xs[:] = [...], the in-place replace — which becomes a splice, not an assignment to a copy.
  • a dict as a dict: dict(other) copies and dict(pairs) builds — the same call in Python, different operations in JS, told apart at runtime. And d.pop(key, default) really removes, instead of falling through to the array pop.
  • Python's truthiness, not JS's: "", 0, None and False agree 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 through truthy$, so if s.errors: answers what Python would. A comparison, a not, 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 d reads a key, instead of falling through to the array .length and .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() is False. The classes are ASCII, like the other predicates'.
  • Form.validate(values) — the one widget method Mode C ports. It fits because its input survives: validators never 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 ,, % and d; with 0Nd it 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. An else on it is still refused, because that one does run.
  • a core event constructor: ThemeChangeEvent(mode=ThemeMode.DARK) and the other 32 events are generated into values.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 storage and from tempestweb.native.geolocation import get_position all 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, LazyRow and LazyGrid materialize the visible window by calling item_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. A scroll event slides the window in the runtime, the way the server does in Mode B, and it survives the view re-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:

app.py:12: `Card` is not available in Mode C (the transpile client exports no such name)

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 transpile produces a directory servable by any CDN; run --mode transpile serves it locally.
  • The same view() from Modes A/B runs here — state, handlers, styled Button/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. 🚀