Chapter 3 · Specs

GUIs when you still ship JavaScript: Webflow, Elm, Javy, Tauri and Rust down to machine code

A field guide to interface options for a stack that keeps its JavaScript and TypeScript: Webflow with TypeScript embeds and Workers side by side, Elm and SVG islands, Shopify Functions compiled from JS with Javy, Rust in the browser through WebAssembly, Tauri around an existing web UI, native Rust GUIs (iced, egui, Slint), and no_std firmware with no GUI at all — compared by what JavaScript remains at runtime and where trust is enforced.

  • architecture
  • security
  • webflow
  • firmware

GUIs when you still ship JavaScript: Webflow, Elm, Javy, Tauri and Rust down to machine code

For the team that has a Webflow site, TypeScript embeds and a Worker today, and keeps being told to rewrite it in Rust.

The question is never how do we get rid of JavaScript. It is which decisions JavaScript is allowed to make — and the answer, in every option below, is as few as possible.


3.35.1 The rule that makes the choice easy

The Ondol Life thermostat states it in one line: the unit enforces every limit; the app only asks. The 49 °C cap lives in firmware compiled for the device's own processor. The phone, the TV and the desktop app may display it, request a change to it, and be refused. None of them can move it.

Read every option in this article against that rule. An interface is safe in proportion to how little it decides, and the language it is written in matters far less than where its decisions are allowed to land. That is the same argument the Dark Factory article makes about the Elm Architecture: the compiler's guarantee holds inside one program, and the moment there is a network in the middle, the edge has to earn it again.

So you can keep your JavaScript and TypeScript. What changes is the job they are given.

3.35.2 The options

1. Webflow with TypeScript embeds, and a Worker bundled beside it

What it is. Webflow owns layout and content. A TypeScript bundle, built with Vite, adds behaviour to the page. A Cloudflare Worker, a second TypeScript bundle, sits in front of the data. Two bundles, one contract between them.

What JavaScript remains. All of it — in the page and at the edge.

Where trust lives. In the Worker and behind it in Xano, never in the page. The page script can be read, modified and replayed by anyone, so it holds no secrets and decides nothing that matters. The contract is a published JSON schema: TypeScript types are generated from it for both bundles, so the page and the Worker cannot silently drift, and the Worker validates every request against it regardless.

Fits. Marketing sites, storefronts, and any interface designers must be able to change without a release. This is the default, and it is a good one, provided the page stays a view.

2. Static HTML with a small behaviour layer

What it is. Eleventy, or Webflow's export, renders HTML ahead of time. petite-vue or HTMX adds the interactive parts.

What JavaScript remains. A few kilobytes, loaded only where a page needs it.

Where trust lives. The same place as option 1. The gain is surface: less script means less to pin under a Content-Security-Policy, less to audit, and less to break.

Fits. Content-heavy pages and forms. The Render and Runtime dictionary defines islands, HTMX and the rest.

3. Elm islands, drawing SVG

What it is. Elm is a typed functional language that compiles to JavaScript and is built on the architecture iced later borrowed: state, typed messages, one update, a pure view. An Elm program mounts into a single element, so it can live as an island inside a Webflow page. It talks to the page and to the Worker through ports, which are typed channels in and out.

What JavaScript remains. One compiled file, with no npm runtime dependencies. Elm's package registry does not accept JavaScript in third-party packages, which removes most of the supply-chain exposure that npm carries by default.

Where trust lives. Inside the island, the compiler guarantees that every message is one the program declared, and Elm is known for programs that do not throw at runtime. At the ports, that guarantee ends: anything arriving from JavaScript is decoded and checked. Past the Worker, it is option 1 again.

The SVG case. elm/svg builds images as typed values, not strings. Elm's virtual DOM refuses <script> elements, inline on* handlers and javascript: URLs, so an interface cannot be talked into running what it draws. For charts, diagrams and interactive drawings, this is the strongest browser-side option in this list. Safe SVG covers the static side of the same problem.

Fits. One complicated, stateful widget — a configurator, an estimator, a floor-plan sketcher — inside an otherwise ordinary page.

4. Shopify Functions written in JavaScript, compiled with Javy

What it is. Javy, a Shopify-originated project now maintained under the Bytecode Alliance, compiles JavaScript into a WebAssembly module. It does this by bundling QuickJS, a small JavaScript engine, with your script's bytecode. It is how Shopify Functions accept JavaScript and TypeScript: you write the discount, cart transform or delivery rule in JS, and what Shopify runs is Wasm.

What JavaScript remains. Your JavaScript, interpreted by a JavaScript engine that is itself inside the Wasm sandbox. This is not an interface; it is the logic half of one, moved off the page and into a runtime that denies it everything.

Where trust lives. In Shopify's sandbox: no network, no filesystem, typed JSON in, typed JSON out, a bounded instruction count. A bug in your function, or a hostile dependency bundled into it, can only affect what that extension point is permitted to change.

The trade. An interpreter inside Wasm costs instructions that compiled Rust does not, so on large carts a JS function reaches Shopify's limits sooner. The usual path is to start in JavaScript with Javy, where the team already works, and move a function to Rust when the numbers say so. The sandbox is the same either way.

Fits. Every checkout rule a merchant needs, written by a team that does not write Rust.

A Javy build is not a safer import — it is a narrower one

It is tempting to read Javy as a more secure way to import JavaScript. It is not an import mechanism at all, and it does not make code safer. The bundler resolves every import at build time, Javy compiles the bundle and a JavaScript engine into one Wasm module, and the code inside is exactly as buggy, or as hostile, as it was before. What changes is what it can reach:

import in a browser page JavaScript compiled with Javy, run by Shopify
When imports resolve At runtime; the browser may fetch modules from URLs At build time; everything is bundled, nothing is fetched later
What the code can reach Whatever the page can: the DOM, readable cookies, fetch to any origin the CSP allows Only the JSON it is handed; it can only return JSON
A malicious dependency could Read the page, skim form input, send it elsewhere Return a wrong discount, which Shopify still checks against what that extension point may change
An infinite loop Freezes the tab Hits the instruction limit and is stopped
Safety comes from The CSP, SRI and the author's care The sandbox the module runs in

Run the same module in a host that grants network or file access and none of the right-hand column holds. The sandbox makes it safe, not the compiler.

The guarantee is an allow-list in both directions. On the way in, each Function declares a GraphQL input query naming exactly the fields it receives — cart lines, prices, customer tags — and nothing outside that query reaches the code. On the way out, it may only return operations from its extension point's schema, and Shopify rejects anything else. It is not a stream: each run is one payload in, one result out, and the Function keeps nothing afterwards.

In Rust's terms, the Function is handed a shared borrow of the cart and returns proposed changes, while Shopify keeps the only mutable reference and applies them itself:

Rust ownership Shopify Function
&T — read, never change The input JSON: the cart can be read, not edited
Only the owner holds &mut T Shopify owns the cart and applies changes itself
The borrow ends with the scope The Function exits after one call and keeps nothing
Checked by the compiler, before anything runs Checked by the sandbox, on every call

The last row is the difference that matters. Ownership is a compile-time rule about memory inside one program. Shopify's is a runtime rule between two parties, which is why it holds just as well for JavaScript, a language with no ownership at all. A Function is an allow-listed, read-only borrow of the cart: it sees only what it asked for, can only propose, and Shopify keeps sole ownership.

The same shape at the payment: separate wallets such as Kakao Pay

Payment wallets apply the same pattern one step later, to the most sensitive data in the transaction. Kakao Pay is a separate, Korean-regulated payment service that runs its own authentication and holds its own card and account data. The Ondol Life Worker integrates it in two server-side calls:

  1. Ready. The Worker sends the order total to Kakao Pay with a secret key that never leaves the Worker, and receives a transaction ID and a redirect URL. The transaction ID is stored server-side, because a transaction ID in a query string is a payment anyone can complete.
  2. The customer pays inside Kakao Pay — its app or its page, under its authentication. The card number is entered there, or was stored there already.
  3. Approve. Kakao Pay returns the customer with a one-time token, and the Worker exchanges the token and the stored transaction ID for the approved result.

At no point does a card number reach the page, the Worker, Xano or Shopify. The merchant receives an approval and an amount — the read-only borrow again — while the wallet keeps sole ownership of the card. Shopify's own checkout works the same way for cards: the storefront never handles a card number.

That is what narrows PCI DSS scope. PCI obligations follow the systems that store, process or transmit cardholder data, so a merchant that only redirects to a wallet and receives a result has far less in scope than one whose pages take card numbers — typically the shortest self-assessment, SAQ A, rather than a full one. Scope narrows; it does not disappear. The merchant still secures the pages that launch the redirect, the secret key, and the approve step, which is why the redirect host must be registered with Kakao Pay and the key is held only by the Worker. The wallet's own compliance is the wallet's: Kakao Pay is supervised under Korea's electronic-finance rules, and a merchant whose acquirer asks for evidence of PCI DSS compliance should request the provider's attestation directly rather than assume it.

There is a sovereignty benefit as well. The customer's card and Kakao account data stay with a Korean provider under Korean regulation. Ondol Life's own systems receive only what the approval contains, which is what lets the Korean launch keep personal data in Korea without moving card data across a border it was never going to cross.

5. Rust in the browser through WebAssembly

What it is. Two shapes. DOM frameworks — Leptos, Dioxus, Yew — render ordinary HTML elements from Rust. Canvas frameworks — egui through eframe — draw the whole interface onto a canvas with WebGPU or WebGL. CADCraft's web build is the second kind: a full AutoCAD-style interface running from one Wasm module.

The three DOM frameworks, defined by the choice each one makes:

  • Leptos — fine-grained reactivity, no virtual DOM. State lives in signals, and when a signal changes only the exact text or attribute that reads it is updated, the approach SolidJS made popular in JavaScript. It is built for full-stack work: the same Rust renders on the server, hydrates in the browser, and calls back to the server through server functions, typed Rust functions that the framework turns into HTTP endpoints. Closest to a Rust Next.js.
  • Dioxus — React's model, many targets. Components, hooks and an rsx! macro that reads like JSX, over a virtual DOM. Its distinguishing bet is reach: one codebase builds for the web, for desktop and mobile (through a system web view, much like Tauri), and for server rendering. Closest to a Rust React Native.
  • Yew — the oldest of the three, and the one nearest Elm. Its original component model was the Elm Architecture almost verbatim — a Message type and an update method — and it later added React-style function components and hooks, with an html! macro over a virtual DOM. It is aimed mainly at single-page applications in the browser.
Leptos Dioxus Yew
Model Signals, fine-grained updates React-style components and hooks Elm-style messages, then React-style hooks
Virtual DOM No Yes Yes
Markup view! macro rsx! macro html! macro
Server rendering Built in, with server functions Built in, with server functions Supported; browser-first
Targets Web (client and server) Web, desktop, mobile, server Web
Nearest JavaScript relative SolidJS / Next.js React / React Native React, with Elm roots

All three still load through a small wasm-bindgen glue file, and all three need 'wasm-unsafe-eval' in a strict Content-Security-Policy. Unlike egui, they render ordinary HTML, so search engines and screen readers see a normal page.

What JavaScript remains. A small generated glue file from wasm-bindgen. It cannot be zero, because Wasm reaches the browser's APIs through JavaScript.

Where trust lives. The same edge as options 1 to 3: Wasm in a tab is still running on the user's machine, under the user's control. What Rust adds is memory safety inside the module and one language from interface to server.

The cost to know about. A strict Content-Security-Policy blocks WebAssembly compilation unless it allows 'wasm-unsafe-eval', which is a narrower grant than 'unsafe-eval' but still a deliberate one. Canvas interfaces also start from nothing for accessibility: screen readers see a canvas, not a form, unless the framework builds an accessibility tree for them.

Fits. Heavy, computational interfaces — CAD, 3D, image editing — where the browser is a delivery channel for an application, not a document.

6. Tauri around the web UI you already have

What it is. A native app whose interface is your existing HTML, CSS and JavaScript, shown in the operating system's own web view, with a Rust process behind it. The Ondol Life thermostat is built this way: the same web app runs as a PWA, as a Tauri desktop app, and later as a TV app.

What JavaScript remains. All of the interface. Tauri replaces the backend, not the frontend.

Where trust lives. In the Rust process, behind an explicit list of commands. The thermostat's frontend can call exactly four: intake_model, content_address, electrical_requirements and size_circuit. Each takes typed arguments that are decoded and checked before any Rust runs, and nothing else exists to call. That list is the Elm Message enum stretched across a process boundary, enforced by the runtime rather than a compiler. The app's Content-Security-Policy lives in its configuration and limits the web view to its own files, its own IPC and named hosts. And the Rust side keeps the parser rule: intake hashes and describes an incoming model and never opens it, because parsing is the attack surface and this process holds the user's filesystem.

Fits. Desktop and mobile apps where the interface is already web, and some work — files, hashing, hardware, sizing calculations — must happen with native access but behind a narrow door.

Electron, for contrast, ships Chromium and Node.js inside every app. It is the same shape, with a far larger bundle and Node's full reach available to code that is not careful to give it up.

7. Native Rust GUIs

No web view and no JavaScript; the interface compiles to machine code for the desktop.

  • iced uses the Elm Architecture directly: State, an enum Message, update, view. The compiler rejects a message the program did not declare.
  • egui is immediate-mode: it redraws the interface from state every frame instead of dispatching messages. It is fast to build with and powers DesignCraft (an InDesign clone) and CADCraft. It shares Elm's rule that state drives the view, without the typed message log.
  • Slint describes the interface in its own declarative markup and compiles it to native code, including for microcontrollers. Of the three it is the one that reaches a device's own screen. Check its licensing terms before shipping, since they differ from the MIT and Apache licences common in Rust.

Where trust lives. Inside one process, the compiler does the work the edge does for options 1 to 5. That is real, and it is also the single-bundle model from the Dark Factory article: correct while nothing crosses the boundary, and back to per-request checks the moment the app talks to a server.

Fits. Professional desktop tools, and kiosk or appliance screens.

egui and eframe, defined

  • egui is a Rust library for drawing an application's interface: buttons, panels, sliders, text fields. It is immediate mode: the program redraws the whole interface from its data, about 60 times a second.

    if ui.button("Insert Page").clicked() { doc.add_page(); }
    ui.label(format!("Pages: {}", doc.pages.len()));
    

    There is no retained tree of widgets — nothing like a web page's DOM — to keep in sync with the data. The program's own data is the only state, and each frame answers "what should be on screen now?", much as Elm's view does.

  • eframe is egui's application shell. It opens the window on macOS, Windows or Linux, connects keyboard and mouse, and hands drawing to the GPU (DesignCraft uses wgpu). The same code can also be compiled to WebAssembly and drawn onto a <canvas> in a browser — CADCraft's web build is that.

In short: egui is the interface library, eframe is the app around it. It is the "total" option in this article — every pixel drawn by compiled Rust, no HTML, CSS or JavaScript, and so no web engine for an Elm panel to live in. Gains: fast, light, one codebase for desktop and web. Costs: it looks the same on every system rather than native, and accessibility and complex text layout take more work than on the web.

8. No GUI on the device: firmware compiled to machine code

What it is. The Ondol Life solar-dock controller is a no_std Rust library with no dependencies, compiled for the RISC-V core of an ESP32-C6. It holds the controller rules, the sensor contract and an 18-byte Bluetooth status record, and nothing that draws.

What JavaScript remains. None on the device. The interface is whichever of options 1 to 7 reads the Bluetooth record.

Where trust lives. Here, and only here. The 49 °C cap, the sensor fault rules and the refusal of an over-limit setpoint (a Bluetooth error returned to the app) are compiled into the unit. Every interface above it can be wrong, compromised or replaced, and the shower still does not go above 49 °C. The firmware's own tests check exactly that, including that a 121 °F request is refused.

Fits. Anything that can hurt someone. This is where the decisions belong; the GUI is a view of them.

3.35.3 Putting it together: Webflow, Beautiful Soup, Eleventy and modules

The Ondol Life site is options 1, 2 and 4 combined into one pipeline, and it is the clearest answer to what do we do with the JavaScript we have. Each layer has one job, and the security layer is never the page.

Design: Webflow. Designers author pages, components and CMS content — including tags and categories per product, per language — in Webflow. Webflow is the authoring tool, not the production server.

Transform: Beautiful Soup. A Python sync reads the published Webflow site through the Webflow Data API and parses each page with Beautiful Soup. Along the way it:

  • lifts the navigation and footer out of every page into shared templates, so they render once;
  • replaces marked elements (data-component="onboard", or a mapped class) with Eleventy components;
  • moves every Webflow-hosted image to R2 under a content-hashed key, served by the Worker with a year-long cache;
  • strips every Google tag Webflow emitted and reports it — the only thing allowed to load a Google tag is the consent script, after consent;
  • refuses to invent a tag: a term that is not in the controlled vocabulary is reported, not accepted.

This is the one place the stack scrapes HTML, and it does so on purpose: Webflow's published output is the authored content, and parsing it avoids writing the same vocabulary twice. It also makes the sync a filter. Whatever Webflow adds to a page, only what the sync recognises reaches production.

Build: Eleventy. Eleventy renders the transformed pages to static HTML from the templates and data the sync produced.

Behaviour: modules. Interactivity arrives as small ES modules loaded with <script type="module"> — a behaviour layer per need, not one application bundle. Every one of them is pinned by the site's Content-Security-Policy, which the site enforces.

Commerce: the Shopify SDKs. Shopify's Storefront Web Components (<shopify-store>, <shopify-context>, <shopify-data>) load as a module with a public Storefront token, which can read the catalogue and create carts and nothing else. Checkout happens on Shopify. Checkout logic runs as Shopify Functions — JavaScript through Javy, or Rust — inside Shopify's sandbox.

Security: Cloudflare and Xano. The Worker sits in front of everything that matters: it serves media, validates form submissions and API calls before anything is written, holds the secrets, and applies consent and, where an endpoint can be abused, a size cap and a per-IP rate limit. Xano holds the records. Its access keys are being split by what each one opens — write, subject, retention and mail — so that a leaked key is bounded by what its kind of door allows, instead of one shared key opening all of them. The page holds no secret and decides nothing; it asks, like the thermostat.

Layer Tool Language Decides
Design Webflow — (visual) How it looks
Transform Beautiful Soup sync Python What is allowed to reach production
Build Eleventy JavaScript (build time only) Which HTML is generated
Behaviour ES modules JavaScript / TypeScript Nothing that matters — it asks
Commerce Storefront Web Components, Shopify Functions (Javy or Rust) JS, Wasm Cart display in the page; checkout rules in the sandbox
Security Cloudflare Worker + Xano TypeScript, XanoScript Every request, every record

JavaScript is present in four of the six layers, and in none of them is it trusted. That is the pattern the whole article argues for.

3.35.4 The comparison

Option You write JavaScript at runtime Runs as Where trust is enforced SVG / drawing Fits
1. Webflow + TS + Worker TypeScript All of it, page and edge Browser + V8 isolate Worker and Xano, per request Browser SVG; sanitise or serve as files Sites, storefronts
2. Static HTML + petite-vue / HTMX HTML, a little JS A few KB, per page Browser Server, per request Browser SVG Content and forms
3. Elm island Elm One compiled file, no npm runtime Browser Compiler inside; decoders at ports Typed SVG; no script, handlers or javascript: One complex widget
4. Javy (Shopify Functions) JavaScript / TypeScript Your JS, inside QuickJS, inside Wasm Shopify's Wasm sandbox Sandbox: no I/O, typed JSON in and out None Checkout and cart logic
5. Rust → Wasm (Leptos, Dioxus, egui web) Rust A small wasm-bindgen glue file Browser Same edge as 1–3 Canvas or DOM; check accessibility CAD, 3D, editors in a tab
6. Tauri Your web UI + Rust All of the UI OS web view + native Rust Typed command allow-list + CSP Browser SVG in the web view Desktop and mobile apps
7. iced / egui / Slint Rust (+ .slint markup) None Native machine code Compiler, inside one process resvg or the framework's own canvas Desktop tools, kiosks, MCU screens
8. no_std firmware Rust None Machine code on the device Compiled into the unit; the app only asks None — there is no GUI Safety limits

3.35.5 Gentle or total: an Elm island in Webflow against a full egui app

Options 3 and 5 both bring a compiler's guarantees into the browser, and teams weighing a move to Rust often jump straight to the second. The difference is scope. An Elm island adds one widget to a site that keeps working as it is. An egui app replaces the whole interface.

The island fits the pipeline above without changing it. The designer places an empty block in Webflow marked data-component="estimator"; the Beautiful Soup sync already maps marked elements to Eleventy components, so the component renders a mount point and loads one compiled file:

<div id="estimator"></div>
<script type="module">
  import "/assets/estimator.js";              // compiled Elm, self-hosted, pinned by the CSP
  const app = Elm.Main.init({
    node: document.getElementById("estimator"),
    flags: { locale: "ko", sku: "ONDOL-120" }  // page config, decoded and checked on arrival
  });
  app.ports.requestQuote.subscribe(q =>       // Elm asks; the Worker decides
    fetch("/api/quote", { method: "POST", body: JSON.stringify(q) })
      .then(r => r.json()).then(app.ports.quoteReceived.send));
</script>
Elm island in Webflow Full Rust egui app
What changes One box on one page The entire interface, rewritten in Rust
Designers Keep designing everything in Webflow Leave the process; the interface is code
Content and CMS Unchanged: Webflow CMS, the sync, Eleventy Rebuilt, or fed in through code
What the browser receives Ordinary HTML with one interactive area One canvas element
Search engines Read the page as usual See an empty canvas
Accessibility Ordinary elements that screen readers understand Extra work before a screen reader sees more than a canvas
Download One compiled JavaScript file, typically tens of kilobytes A Wasm module, usually megabytes
Content-Security-Policy No change Must allow 'wasm-unsafe-eval'
Learning curve A small functional language, no memory rules Rust's ownership and borrowing, plus the egui API
Toolchain elm make, one file out Rust, a Wasm target, and Trunk or wasm-bindgen
If it does not work out Delete one box; the site is unchanged Going back is a second rebuild

The underlying reason is who draws the pixels. egui draws its own — text, layout, every widget — so it brings everything with it and gives up what the browser provides for free: HTML, search, screen readers, and the designers' tool. Elm renders ordinary HTML and SVG, so it takes over only the part it is handed.

Neither moves the trust boundary. The island asks through a port and the Worker validates the request like any other; an egui app in a tab is equally under the user's control. Elm makes the widget correct. Only the edge makes the system trustworthy.

egui is still the right call when the browser's free features do not matter and rendering does: CAD, page layout, 3D editors, and one Rust codebase shipped to both desktop and web, as CADCraft and DesignCraft are. Those are applications that happen to run in a tab. A site of content, products and a few interactive tools is not, and for that the gentle path fits: Webflow for the pages, an Elm island where one tool's state gets complicated, and Rust where the safety limits live.

3.35.6 Elm in a design tool: inside an InDesign panel, beside DesignCraft

The Webflow pattern — a host that owns the page, an Elm island that owns its state, typed messages between them — carries over to desktop publishing. Whether the island goes inside the tool or beside it depends on one thing: whether the tool has a web engine to put it in.

InDesign: Elm inside a UXP panel

InDesign extension type Does Elm run? Notes
UXP plugin panel (current; Adobe's replacement for CEP) Yes — best inside a <webview> in the panel UXP's own interface implements only part of the browser DOM, and inline SVG is limited. A webview gives Elm a full browser engine, so Elm and elm/svg behave as on the web. Confirm the InDesign version supports webviews in panels, and declare it in the plugin manifest
CEP extension panel (older, Chromium-based) Yes, unchanged Full Chromium, so any Elm app works. CEP is being phased out; don't start new work on it
Interactive documents (Publish Online, fixed-layout EPUB, HTML5 animation) Barely These live inside the exported document, not in InDesign. Script support is narrow and differs by reader
Elm app (webview)  ──port: Msg out──►  UXP plugin JS  ──►  require("indesign") DOM
                   ◄──port: data in──                  ◄──  selection, pages, styles
  • Elm keeps the panel's state, in its model, changed only through update — no state-manager library, as in the Webflow form.
  • Elm cannot reach InDesign. It can only send messages you defined, such as ApplyStyle "Heading 1" or PlaceAsset id, through a port.
  • The plugin JS is the allow-list. Only it can call require("indesign"), and it decides which messages become document changes: Elm proposes, the host decides — the Shopify Functions and Webflow shape again.
  • Data coming in is decoded. JSON crossing the port goes through Elm's typed decoders, so a malformed payload is refused at the border instead of crashing the panel.

It suits panels that are mostly forms and state: an asset picker, a style or pre-flight checklist, a CRM or catalog lookup feeding a layout. Limits: the plugin still needs its JS layer; a webview costs load time and memory against native UXP controls; and a plugin ships through Adobe's packaging and marketplace, not a script tag.

DesignCraft: Elm beside an egui app

DesignCraft, the open-source InDesign clone in Rust (v0.6.0, examined from the installed app), is the opposite case. It is built on egui (egui/eframe 0.36.2, drawn with wgpu) — the "total" option above, every pixel Rust — and no web engine is compiled in: no wry or other WebView, no JavaScript engine. There is nowhere inside it to load Elm; a panel there is egui code.

What it has instead are two doors, both taking data rather than code:

  • Window → Interactive → Scripts runs JSON command lines — command.id {json}, one per line or as a JSON array, with $N.path referring to an earlier command's result — not JavaScript or ExtendScript. Objects are found by script label (object.findByLabel), so scripts and agents address frames by name.
  • A control server, started with --control or DESIGNCRAFT_CONTROL_PORT, listening on 127.0.0.1 only. Each request is a JSON object with method and params; anything else is refused ("not a control request … closing the connection"), and the number of connections is capped.

So the Elm app runs next to it and drives it:

Elm UI (Tauri window or browser)
   └─ port: Msg ──► small bridge (Tauri command / local process)
                       └─ {"method": …, "params": …} ──► DesignCraft control server (127.0.0.1)

The bridge is needed because the control server appears to be a raw local socket, which a browser page cannot open. Tauri fits: its command allow-list becomes the list of DesignCraft methods the panel may call — the same arrangement as the thermostat app. The method names themselves are not readable from the binary; take them from the DesignCraft repository or designcraft --help before building against them.

InDesign (UXP) DesignCraft
Elm inside a panel Yes, in a webview No — egui has no web engine
Elm beside it, driving it Possible Yes — JSON to the local control port
Scripting language JavaScript (UXP) JSON command lines
What the tool accepts from outside Whatever the plugin's JS calls Well-formed {method, params} only; no script code is run

That last row is the argument of the rest of this book in one line. InDesign extends by running more code inside it; DesignCraft extends by accepting command records — JSON with a fixed shape, from this machine, refused if malformed. The narrower door is the one built for an age of agents writing the requests.

3.35.7 Updating: what recompiles, and how the Worker reaches Webflow

Every compiled option raises the same practical question: when something changes, is it a rebuild? For code, yes. For data, no. Designing for that split decides how often anyone has to touch a compiler.

A Wasm dashboard

Change Recompile? Why
Rust logic, components, a new chart type Yes The code is compiled into the .wasm file
Markup inside rsx! or view! Often not, while developing Dioxus and Leptos can hot-reload markup edits without a full rebuild
CSS in a separate stylesheet No The browser loads it on its own
The dashboard's data — orders, leads, sensor readings No Fetched at runtime from the Worker or Xano as JSON
Settings, flags, labels, translations No, if loaded as JSON at startup Yes only if they were compiled in as constants

In development, trunk serve (Yew, egui), dx serve (Dioxus) and cargo leptos watch (Leptos) rebuild on save, and incremental builds take seconds; the first build and an optimised release build take longer. On deploy, a code change produces a new .wasm file with a content-hashed name, so browsers fetch it instead of a cached copy. Data changes go live the moment the API returns them. Compile the logic, fetch the data — the same rule as the JSON-records section of the Dark Factory article. The other compiled pieces follow it too: the TypeScript Worker is rebuilt and redeployed with wrangler deploy, and a Shopify Function is recompiled and published as a new app version with shopify app deploy.

Webflow's site-wide Head code and Footer code are the obvious place for behaviour, and the worst place to keep it: pasted code there has no build, no tests, no version history and no review. So the stack keeps exactly one line in Webflow and everything else in the Worker:

<!-- Webflow → Site settings → Custom code → Footer code. The only line written in Webflow. -->
<script src="https://<worker>/embed/webflow-preview.js" data-sitekey="<turnstile site key>" defer></script>

The loader reads the Worker's address from its own src, so that line is the only place the address is written. Everything it loads — the header's account menu, the chat launcher, the newsletter form handler — is built and served by the Worker:

  • Source. Components are ES modules in the repository, and header and footer markup are design files. Both are the source of truth.
  • Compile. A build step turns them into what the Worker serves. Design HTML is JSON.stringify-ed into a generated TypeScript module, which is immune to the backticks, ${ and backslashes that break hand-pasted code. The modules can also be flattened into one classic-script bundle, with each component in its own scope, for pages that need a single embed. Generated files say so at the top and are never edited by hand.
  • Ship. wrangler deploy. The new header, footer or form behaviour is live on the next page load, with no change in Webflow.

The built Eleventy site and Webflow's own .webflow.io copy stay in step through the same mechanism. On the built site, the Beautiful Soup sync swaps marked elements for components at build time. Webflow's hosted copy is never built, so the loader performs the same swap in the browser — and on the built site it finds nothing left to swap and does nothing.

Webflow components: who designs, who decides

Component Webflow owns The Worker's code owns Change it by
Header Layout, logo, navigation, an empty element marked component-account The <ondol-header> account menu that replaces that element: sign-in, session Design: publish in Webflow. Behaviour: rebuild and deploy
Footer Layout, links, the newsletter form's design The chat launcher, the newsletter submission, consent Same split
Form The fields, the button, the success block (.w-form-done) and the error block (.w-form-fail), and their text The submission only: it detaches Webflow's handler from that one form, posts to Xano — or to the form's own action when the designer set one — and then shows Webflow's own success or error block Copy and layout in Webflow; endpoint and rules in code
Validation The browser check: required fields and boxes stop an empty or unticked submission before anything is sent The real check, in the Worker and Xano: the email, the consent, and in Korea the itemised cross-border disclosure and its own required box The browser check is courtesy; the server check is the authority
Load Nothing beyond the one loader line Deferred scripts, each component defined once, one run per page even when two addresses load it, Google tags only after consent, all within the Content-Security-Policy Rebuild and deploy
State None that matters Interface state in the component — the button disables while sending and comes back if Xano refuses; durable state in Xano; the visitor's consent regime from the Worker Records change in Xano with no compile at all

Two details carry the rest. Webflow's own spam protection is removed from the newsletter form: it waits for a Turnstile token issued for webflow.io, which never arrives on the real domain, so it would leave the button permanently disabled. Abuse is handled where it can be enforced instead — Xano rate-limits sign-ups by IP, and EU sign-ups are confirmed by email before they count. The browser also applies the same email rule Xano does, so a mistake shows before anything is sent. And every one of these behaviours is pinned by an end-to-end test against Webflow's real markup — that Xano is posted to rather than Webflow, that Webflow's error block keeps the designer's text and the button returns, that a Korean visitor sees the disclosure in the form — so a designer's edit in Webflow and a code change in the Worker are checked against the same page.

Which forms still need the Turnstile checkbox

Removing Webflow's checkbox from the newsletter does not mean the site has no Turnstile. Turnstile is Cloudflare's checkbox (reCAPTCHA is Google's), and the Worker checks a token from its own widget, on the real domain, wherever a form can be turned into a weapon or a cost:

Form Turnstile Why
Newsletter No Webflow's widget is bound to webflow.io and can never pass on the real domain. A sign-up sends nothing and buys nothing, so Xano's per-IP rate limit and email confirmation for EU sign-ups are enough
Email sign-in link Yes, before anything is sent Without it, the endpoint mails a link to any address typed in — a free way to flood someone's inbox from your domain
Order and inquiry Yes The buy button can be embedded on any site, so an order could be created from anywhere. Every site that embeds it must be on the widget's hostname list
Loyalty enrolment Yes Same discipline as leads

Turnstile is anti-abuse, not a security boundary. It raises the cost of volume; it does not decide what may be stored. The consent gate and the content-address check hold with it switched off, which is why the Worker lets requests through when Turnstile is unconfigured — and logs every time it does. A consent gate that let requests through would be a violation; a bot filter that does is a site that still works.

If the newsletter ever does attract sign-ups the rate limit lets through, the fix is the same as on the other forms: a Turnstile widget registered for the real domain, with the Worker verifying the token. Switching Webflow's checkbox back on would only bring back the disabled button.

A Webflow form, end to end, with no framework and no state manager

The newsletter form is the whole pattern in one component, and it needs nothing beyond what the browser already has: no React, no Redux, no Elm runtime, no build step that rewrites the code. TypeScript checks it but ships nothing of its own.

Attributes are the configuration. The script tag carries everything that changes between sites and markets, so the code holds no hard-coded hosts:

<script src="https://<worker>/embed/newsletter.js" defer
  data-endpoint="<Xano /newsletter>"
  data-endpoint-kr="<Korea Xano /newsletter, or empty>"
  data-lang="en"
  data-regime="https://<worker>/consent/regime"
  data-disclosure="https://<worker>/legal/disclosure?form=newsletter&lang=en"
  data-terms-version="…"></script>

The form itself is found by Webflow's own markup (.newswidth form) or by an attribute the designer can add to any form (data-ondol-newsletter). If the designer sets the form's Action in Webflow to an https:// URL, that wins over data-endpoint.

POST to Xano. The script detaches Webflow's handler from that one form and sends a JSON POST with credentials: "omit", so no cookie rides along. The body is the email plus what the consent record needs: the region and regime from the Worker, each box's state and the time it was ticked, the exact label text the visitor saw, the terms version and, in Korea, the disclosure. A Korean visitor posts to the Korea Xano once one is configured.

Validation in two places, one rule. The browser check uses the input's pattern attribute, set to the same rule Xano applies, so a mistake shows before anything is sent:

[^@\s]+@[^@\s]+\.[^@\s]+

That reads "something with no spaces or @, then @, then the same, a dot, the same." A pattern the designer has already set in Webflow is kept. The pattern attribute is a courtesy; Xano applies the same rule again and is the authority.

Success, error, redirect.

Outcome What the visitor sees
Success The form hides and Webflow's own success block (.w-form-done) shows. If Xano says the sign-up is pending — an EU sign-up waiting for email confirmation — its text changes to say so. An ondol:newsletter event fires for anything else on the page that wants to know
Error Webflow's error block (.w-form-fail) shows with the designer's text, and the button comes back
Redirect Not followed. Webflow's "Redirect URL" setting is ignored on this form: the success block is the result. Where the codebase does redirect — the Kakao Pay button — it only follows an address the server returned, never one written into the page

Eager and lazy loading. Loading is decided by attribute and by where the component sits on the page:

How Used for
Eager <script defer> in the page — downloads alongside it and runs once the HTML is parsed; scripts the loader adds run as soon as they arrive The loader, the consent stack, the newsletter, the header's account menu, chat
Lazy An IntersectionObserver with a margin (400–600 px) starts the component only as the visitor scrolls near it The 3D model viewers and the scroll story — the heavy parts
On demand The loader adds a <script> only when the page has the element it serves, and never twice The newsletter script is added only if a newsletter form exists

State without a state manager. Each piece of state lives in the one place that already holds it:

State Lives in
Sending, so a double click does not post twice An attribute on the form: data-sending
The button while sending The button's own disabled and its label, restored afterwards
When each box was ticked An attribute on the box: data-checked-at
Webflow's drawn checkbox matching the real one A class on the drawn box: w--redirected-checked
The visitor's consent regime Fetched once from the Worker into a variable inside the component; an unknown region gets the stricter rules
Anything durable Xano

The DOM is the store. Webflow's own classes and blocks are the view. Nothing here needs synchronising, because nothing is kept in two places.

TypeScript, shipped as plain JavaScript. The embeds are written as plain JavaScript and served exactly as written. TypeScript runs in the Worker's typecheck script with checkJs and noEmit: it reads the JavaScript, checks it against the Worker's types, and produces no output. So the file that is tested is the file that is served, a Webflow embed is plain JS, HTML and CSS — the component's styles are a string it injects once, scoped under [data-ondol-form] — and there is no compiled bundle to fall out of date. The one generated file, ondol-bundle.js, is a concatenation of these modules for pasting into a Webflow Embed, not a transpile.

A/B gating: Xano decides, the Worker serves, a vanilla loader picks

The same split extends to experiments without adding a testing vendor's script to every page. This is a proposed pattern, not yet built into Ondol Life, and it uses only the pieces above:

  1. Xano decides the variant. A conditional in an endpoint's function stack — if this subject is eligible and their hashed ID falls in the first half, then b, else a — reads the split from an experiment record, not from code. Eligibility is part of the same decision: market, signed-in or not, and whether the visitor's consent allows the result to be measured. Because the subject's ID is hashed, the same visitor always gets the same variant.
  2. The Worker serves both builds under namespaced paths. Both variants are compiled ahead of time and deployed together, each under its own immutable path — /embed/exp/newsletter-copy/a/newsletter.js and .../b/newsletter.js. Same origin, so the Content-Security-Policy does not change. The Worker asks Xano once per subject and caches the answer, so a page load does not wait on the database.
  3. A vanilla loader picks. The one loader line in Webflow's footer is unchanged. It asks the Worker for the assignment and loads the matching path — plain JavaScript, no framework, nothing to install.

Three rules keep it honest:

  • A variant changes presentation, never authority. It may change copy, layout or the order of steps. It may not change a price, a limit, a consent requirement or what the server accepts — the Worker and Xano validate both variants identically. The thermostat's boundary applies: a gate may change what a feature looks like, never move a ceiling.
  • The assignment is made on the server. A coin flip in the browser is fine for a colour and useless for anything that matters, because the visitor controls it. Xano's answer is the one that counts.
  • Rollback is a record change. Set the split to 100% a in Xano and every visitor is back on the original at the next page load, with no compile and no deploy. Ending an experiment is a deploy that removes the losing path.

The same gate for market rules — with one difference

The mechanism carries over directly to if US, if Korea, if Canada: which form rules, newsletter rules and payment components load for a visitor. The difference is that a market rule is not an experiment. It is never a percentage split, it is never left to a variant record someone might edit for a test, and when the market is unknown it fails toward the stricter rules, not toward the default. The Worker already works this way: it resolves the visitor's consent regime on the server, and the newsletter handler treats an unknown region as needing the Korean disclosure.

Most market differences are rules, and rules are data, so they belong in one compiled bundle, read from the Worker at load time:

Rule United States Korea Canada
Where the sign-up is stored The US Xano The Korea team's Xano, once configured — the data stays in Korea The US Xano
Cross-border disclosure None Until Korean storage is live: the itemised disclosure and its own required box, in the Webflow form None itemised
Consent model Opt-out, honouring Global Privacy Control Opt-in before collection Opt-in (CASL), and the sign-up counts only after the confirmation email is clicked
Language English Korean, including error and confirmation text English; French for Quebec is not built
If the rules cannot load — The form says so and the button stays off Same as Korea

Changing any of these is a record or configuration change, with no compile. Separate, namespaced bundles are for code that only one market needs — the Kakao Pay buy button is the example, loaded only where Kakao Pay is offered — so a US visitor never downloads it at all. The test is the one this whole section uses: if it is a rule, fetch it; if it is code, compile it, and let the server decide which code a market receives.

Korea, US, Canada as a closed type

Rust and Elm have no directives — v-if and ng-if belong to Vue and Angular. What they have is better for market rules: a closed type, a list of markets the compiler knows is complete, and a match (Elm: case) that must answer for every one of them.

enum Market { Korea, Us, Canada }

fn newsletter_rules(m: Market) -> Rules {
    match m {
        Market::Korea  => Rules { consent: OptIn,  confirm_by_email: false, cross_border_box: true,  lang: Ko },
        Market::Us     => Rules { consent: OptOut, confirm_by_email: false, cross_border_box: false, lang: En },
        Market::Canada => Rules { consent: OptIn,  confirm_by_email: true,  cross_border_box: false, lang: En },
    }
}

Add Canada to the enum and the build fails until every rule says what Canada gets. That is the whole benefit, and it is not a style preference. The Ondol Worker keeps its opt-in markets in a JavaScript Set, and a set has no idea what is missing from it: Canada was not in it, so Canadian visitors fell through to the default — opt-out, every Google Consent Mode signal starting granted, and eligible for ad audiences before they had agreed to anything. Under CASL, Canada's anti-spam law, marketing email generally needs express consent first. A match would have refused to compile; the Set said nothing.

The fix was three lines of data, which is the point of rules-as-data — no new bundle, no new component:

  • Worker: CA joins the opt-in list. A Canadian visitor now starts with ad_user_data and ad_personalization denied and is not audience-eligible until they say yes. The Worker does not resolve provinces, so treating all of Canada as opt-in also covers Quebec's stricter Law 25.
  • Xano: CA joins the double-opt-in list. CASL puts the burden of proving consent on the sender, and the click on the confirmation email is that proof.
  • A test that asks the live route for US, Korea, Germany, Canada and an unknown country, and fails if any of them starts on the wrong side.

The JavaScript still cannot tell you that a fourth market is missing; only that test can, for the markets it names. That is the honest trade: in Rust or Elm the compiler checks completeness for free, and in JavaScript a test has to.

CASL: what a North American newsletter sign-up needs

The US and Canada sit side by side on the same form and run opposite laws. The US federal law, CAN-SPAM, is opt-out: a business may email first, so long as every message identifies the sender and the unsubscribe works. Canada's Anti-Spam Legislation (CASL) is opt-in, and among the strictest anywhere: no commercial email at all without consent first, and the sender must be able to prove it. CASL follows the recipient, not the sender — a US business emailing someone in Canada is covered.

Requirement US (CAN-SPAM) Canada (CASL) Where the Ondol form stands
Consent before the first email Not required Required. Express consent: the visitor acts; a pre-ticked box does not count. Implied consent exists only for narrow cases such as a recent purchase or inquiry The opt-in box starts unticked and Xano refuses a sign-up without it
What the request must say — Why consent is sought, who is asking, how to contact them, and that consent can be withdrawn The box's label and the terms it links to; the exact label text is stored with each sign-up
Proof of consent — The sender's burden Each sign-up records the box, the time it was ticked, the label and the terms version. Canadian sign-ups now also confirm by email (double opt-in)
Every message Sender, postal address, working unsubscribe Sender, contact details, working unsubscribe, honoured within 10 business days Built, not yet deployed. A signed link in every issue opens an unsubscribe page that acts only on a click, since link scanners open every URL. The mail client's own Unsubscribe button posts to the Worker (one-click, RFC 8058). Links never expire, and unsubscribing deletes the stored address and keeps only a one-way fingerprint so nothing can be sent to it. Nothing sends the newsletter yet
Maximum penalty Per-email civil penalties Up to $10 million per violation for a business, $1 million for an individual —

Is it enforced? Rarely, but there is precedent, and the precedent is the reason to comply rather than gamble:

  • CompuFinder. The CRTC's first contested case: a $1.1 million notice for emails sent without consent and with broken unsubscribes, reduced to $200,000. The company then challenged CASL itself; the Federal Court of Appeal held it constitutional in 2020 (3510395 Canada Inc. v. Canada (AG), 2020 FCA 103), and the Supreme Court of Canada declined to hear an appeal (summary). The law's validity is settled.
  • Plenty of Fish. $48,000 for a technical failure alone — an unsubscribe mechanism that was not clear and simple enough — settled by undertaking (summary). A business with consent can still be penalised for the unsubscribe.
  • Since then, very little. In 2023 the CRTC published one CASL enforcement action, against a phishing campaign (2023 review). Most matters close quietly as undertakings. The private right of action that would have let individuals sue was suspended before it took effect, so the regulator is the only enforcer.

Shopify is Canadian, and that changes less than it seems. CASL attaches to the message and its recipient, not to the platform's home country, and the merchant — not Shopify — is the sender. A store on Shopify emailing someone in Toronto owes CASL consent; the same store emailing someone in Ohio does not, even though both run on Shopify. What Shopify's Canadian roots do give is a customer record built for it: each customer's emailMarketingConsent carries a marketingState, a marketingOptInLevel (SINGLE_OPT_IN, CONFIRMED_OPT_IN, or UNKNOWN) and consentUpdatedAt. Today the Ondol newsletter lives only in Xano. If subscribers are ever synced to Shopify to send through Shopify Email, a confirmed Canadian sign-up should arrive as CONFIRMED_OPT_IN with the time of the click, so the proof travels with the address — and an UNKNOWN should never be emailed in Canada.

So the honest read is: low probability, settled law, high ceiling, and the cheapest violations to prove are the technical ones — no record of consent, an unsubscribe that does not work. Those are exactly the parts a form and a database can get right once, which is why this codebase does consent as data and leaves no market to a default. This is a summary for engineers, not legal advice; confirm the specifics with counsel before the first send.

One browser detail decides how components are namespaced. A custom element's tag name can be defined only once per page, so if both variants define <ondol-newsletter>, the loader must make sure only one of them ever loads. Variants that need to coexist on a page, for a side-by-side preview, take namespaced tags such as <ondol-newsletter-b>.

The pattern in one line: Webflow designs every component, and the Worker decides what each one does. Design changes publish from Webflow with no compile. Behaviour changes compile once, in the Worker, and reach every page through one line nobody edits.

3.35.8 Elm, Eleventy + Xano or React: isKorea, isConsented, isForm

These three are often weighed as alternatives, but they answer different questions. Eleventy + Xano decides what HTML exists, at build time. Elm decides how one widget's state changes, in the browser. React usually decides both, and brings its own runtime to do it. The clearest comparison is to give each the same three flags every page in this stack carries — is this Korea, is this visitor consented, what is this form doing — and see where each one keeps them, and what each can draw.

State without useState

In React, state is a hook. useState is imported from react and works only inside a component React is rendering, so every stateful piece of interface depends on the React runtime and ReactDOM — and, once hooks stop scaling, on a store library as well. The other two need no state library at all:

Where state lives Eleventy + Xano Elm island React
At build time Data files fetched from Xano — public data only — — (or a server render, which runs before consent)
In the browser The DOM: attributes and classes, as in the newsletter form above One Model value, changed only in update useState and useReducer hooks, Context, often a store library
Durable Xano Xano, through a port and the Worker Xano, through a fetch in an effect
Library needed for state None None — it is the language react and react-dom, at minimum
Can two pieces of state disagree? Only if one fact is kept in two places, which the pattern avoids Not if the type rules it out Yes: separate hooks are separate facts

isKorea

A Boolean is the wrong shape for a market. isKorea answers one question and says nothing about Canada — the exact gap the Set had above.

Eleventy + Xano. Market rules are public data, so the build can fetch them and render each market's page from them. A sketch:

// _data/markets.js — runs at build time; public rules only
export default async () => {
  const markets = await (await fetch(`${process.env.XANO_PUBLIC}/markets`)).json();
  for (const m of markets)
    for (const key of ["consent", "lang", "storage", "crossBorderBox"])
      if (!(key in m)) throw new Error(`market ${m.code} has no ${key}`); // fail the build, not the visitor
  return markets;
};

The template's {% if market.crossBorderBox %} then renders the disclosure into the Korean page. The check in the loop is the closed type's job done by hand: it catches a market missing a field, not a market missing from the list. Which market a visitor is in is still decided at runtime by the Worker, never by the build.

Elm. The market is a type, and "the Worker hasn't answered yet" is a value of it:

type Market = Korea | Us | Canada
type Regime = Resolving | Known Market

crossBorderBox : Regime -> Bool
crossBorderBox regime =
    case regime of
        Resolving    -> True     -- unknown: fail toward the stricter rules
        Known Korea  -> True
        Known Us     -> False
        Known Canada -> False

Add a market and every case like this one stops compiling until it answers. Avoid the _ -> wildcard in market rules: it compiles, and it reopens the gap.

React.

const [isKorea, setIsKorea] = useState(false);   // renders as "not Korea" first
useEffect(() => {
  fetch("/consent/regime").then(r => r.json()).then(r => setIsKorea(r.region === "KR"));
}, []);

The initial value is a default, and it renders before the Worker answers: a Korean visitor first sees the US form, without the disclosure box. Starting at true fixes Korea and still says nothing about Canada. React can model this properly — a null start and an explicit loading branch — but nothing makes you.

isConsented

Consent has at least three states — not asked, granted, denied — and under Consent Mode v2 it is per purpose. A Boolean folds not asked into one of the other two, and whichever it picks is a bug.

Eleventy + Xano. The build never knows a visitor's consent, and does not need to: it fetches only public data, so nothing it renders depends on anyone's choice. Personal data is fetched at runtime, through the Worker, which checks consent itself (Consent Resolution). The safest place to hold isConsented is somewhere the build never has to look.

Elm.

type Consent = NotAsked | Granted | Denied

update msg model =
    case msg of
        QuoteRequested ->
            case model.consent of
                Granted  -> ( { model | form = Sending }, requestQuote (encodeQuote model) )
                NotAsked -> ( { model | showBanner = True }, Cmd.none )
                Denied   -> ( model, Cmd.none )

The only way out to the network is a port command returned from update, and the only branch that returns one is Granted. A view cannot fetch. A reviewer reads one case to know when data leaves.

React. Any component can fetch in a useEffect, so the consent check has to be remembered in every one of them, usually through a ConsentContext. Forget it once and the request goes out on mount, before the banner is answered. With server components or server rendering, the fetch can happen before the page reaches the browser at all — gap 12 in the security gaps illustration.

isForm

A form is not a flag but a sequence: editing, sending, done or pending confirmation, failed.

Eleventy + Xano. Webflow's markup is the form and the DOM is the store: data-sending on the form, the button's own disabled, Webflow's .w-form-done and .w-form-fail blocks. That is the newsletter form above, and for a sign-up it is enough.

Elm.

type FormState
    = Editing
    | Sending
    | Done Outcome          -- Subscribed | PendingConfirmation
    | Failed String

One value, so the form cannot be sending and failed at once, and the button's disabled is worked out in view rather than stored. Worth it when the form branches — a multi-step quote, a step only one market sees — not for a single email field.

React.

const [email, setEmail] = useState("");
const [isSending, setIsSending] = useState(false);
const [isError, setIsError] = useState(false);
const [isDone, setIsDone] = useState(false);

Three Booleans make eight combinations, of which four mean anything, and every handler has to reset the others correctly. useReducer with a tagged union fixes the shape and TypeScript can check it — that is Elm's model, kept by convention instead of enforced by the language. In Webflow, a React form also replaces Webflow's own: the designer's success and error blocks are no longer the ones that show.

SVG and design capability

Eleventy + Xano Elm island React
Who designs it Designers, in Webflow; synced and built Designers style it; developers draw it Developers; designers set props
Static SVG — icons, illustrations Sanitised and inlined at build, or served as files (Safe SVG) Usually left to the page around it Imported as components at build, or pasted as strings
Data-driven drawing — charts, floor plans, configurators Only what can be computed at build; anything live needs a script Strongest: elm/svg draws from the model, as typed values Good: JSX draws SVG elements from state
Can a drawing run code? Not once sanitised, and never through <img> No: no <script>, on* handlers or javascript: URLs Yes, through dangerouslySetInnerHTML with an SVG string
Do Webflow's classes apply? Yes — it is Webflow's markup Yes: Elm renders into the page's own DOM, so Webflow classes work as written Not by default: Webflow code components render inside a shadow root, isolated from the site's styles
Animation GSAP or CSS from the behaviour layer CSS, or GSAP through a port A React animation library, or GSAP through refs
What crawlers and agents see The whole drawing, in the HTML An empty mount point until the script runs An empty mount point, unless server-rendered

Which, for what

  • Pages, content, products and sign-up forms: Eleventy + Xano, with Webflow designing. isKorea and isConsented are server decisions there, and the build has no reason to know either.
  • One widget whose state branches by market and consent, or whose drawing is computed: an Elm island inside those pages.
  • React when designers must place the component in Webflow's Designer and set its props there — Webflow's code components are React — or when an existing React codebase is the reason. Then give it Elm's shape by hand: one reducer, a tagged union for each flag, a null while the regime resolves, and no fetch outside a consent check.

None of them moves the boundary. For all three, the Worker resolves the market, checks the consent and validates the form.

The same infrastructure under any theme build

Nothing above lives in the theme. Webflow designs, the Cloudflare Worker decides, Xano keeps the record — so the three flags and the SVG rule carry unchanged to AEM, WordPress, Shopify, Next, Nuxt, Astro, Svelte and Vue. Per platform only three things change: where a fragment or island mounts, what the platform offers in place of useState, and what it does with an SVG. Fragments on any frontend covers the mounting; this table covers state and drawing.

Theme build Mounts the fragment or Elm island in Its own state tool isKorea · isConsented · isForm SVG and widgets
AEM / Edge Delivery A block's decorate() None — the block's DOM, like the newsletter form The Worker Icons ship as files in the project; block JS loads only where the block is used
WordPress A block or shortcode, fetched server-side; the island's script enqueued as a module The Interactivity API — directives over server-rendered state The Worker; PHP renders, it does not decide SVG uploads are refused by default. Ship sanitised SVG from the theme or the Worker, not through the media library
Shopify theme A Liquid section or an app block None — Liquid renders once, the DOM after that The Worker; Liquid cannot enforce SVG is not a native asset choice — merchants cannot pick one in the theme editor or product media. SVG reaches a theme only as a developer's theme asset, inlined with inline_asset_content, or from the Worker
Shopify checkout and accounts A checkout UI extension — no island, no fragment Shopify's extension API The extension asks the Worker, once network access is approved; checkout logic runs in Functions No custom widget or SVG. See below
Next A client component that mounts Elm in useEffect useState The Worker; a server component must not fetch personal data before consent (gap 12) next/image refuses SVG unless dangerouslyAllowSVG is set; import static SVG at build
Nuxt / Vue <ClientOnly>, mounted in onMounted; in a Webflow embed, petite-vue ref() and reactive(), Pinia in Nuxt The Worker; Nuxt's server render has Next's caveat v-html is Vue's dangerouslySetInnerHTML
Astro An island with client:visible; static by default None required — each island brings its own The build fetches public rules, as Eleventy does; the Worker decides the rest Static SVG inlined at build — the closest to Eleventy
Svelte / SvelteKit onMount $state runes and stores, compiled away The Worker; a load function on the server has Next's caveat {@html} is the opening

Read down the last column and one pattern repeats: every platform either names its way of turning an SVG string into live markup as a hazard, or refuses SVG outright — dangerouslySetInnerHTML, v-html, {@html} and dangerouslyAllowSVG on one side; WordPress's refused upload and Shopify's missing asset choice on the other. Elm has none, and Eleventy's sanitiser runs before any of them see the file.

Shopify blocks the widget and the SVG — and that is the boundary agreeing

On the storefront theme, an Elm island or a sanitised SVG works as on any other page. Past the cart, it does not. Checkout, the thank-you page and customer accounts accept only checkout UI extensions, which run sandboxed in a web worker, off the page's DOM, and draw only with Shopify's own components. An extension can be written in React or Preact, but your code never touches the DOM: there is no <script>, no custom HTML, no inline SVG, and no Elm runtime to mount. Shopify has retired checkout.liquid, which used to allow those things.

That is not something to work around. It is the same split this article argues for, enforced by Shopify:

  • Decisions — discounts, delivery rules, payment rules — run as Shopify Functions, in Javy or Rust (option 4).
  • Data — the market, the consent regime, a quote — reaches the extension from the Worker, which checks it as it would for any other caller.
  • Drawings — the configurator, the floor plan, the computed chart — stay on storefront pages, where the island can draw them. Checkout receives the result as a line item or a property, not the widget.

The storefront has its own version of the block. Shopify excludes SVG as a native asset choice: a merchant cannot select one as an image setting or as product media, so nothing a merchant uploads can become live SVG markup on the page. An SVG on a Shopify storefront is therefore always one somebody shipped on purpose — a theme asset in the repository, or a sanitised file from the Worker — never one that arrived through the admin. That makes the Elm island the natural way to draw on Shopify: the drawing is code, built and reviewed with the theme, not an upload.

Elsewhere, an uploaded file should follow the same rule: served as a file and shown through <img>, which never runs it (gap 3). Shopify and WordPress enforce that by default; AEM, Next and the rest leave it to you.

Why React with a state manager does not fit AEM, WordPress or Shopify themes

"You can't use React" is not strictly true: a WordPress plugin, a Shopify app block or an AEM block can each mount a React island. What does not work is React with a state manager as the way the theme is built. A state manager assumes React owns the page. On all three platforms, the platform owns the page.

  1. The server that renders the HTML is not JavaScript. AEM renders with HTL and Java, and Edge Delivery serves plain HTML. WordPress renders with PHP. Shopify renders with Liquid. React can only take over HTML that React itself rendered on a Node server — hydration — and none of these run one. So React builds its part in the browser, from an empty <div>: visitors and crawlers see a blank space until the script loads, and the layout jumps when it fills in.
  2. A theme page is many separate roots, and state does not cross them. Editors compose pages from independent sections, blocks and components — in Shopify's theme editor, in Gutenberg, in AEM's Universal Editor. Each React island is its own createRoot, and useState, Context and a Redux store do not reach across roots. The facts the page shares — the cart count, the market, the consent — end up in DOM events or a global anyway, so the state manager adds a dependency without doing the job.
  3. The platform rewrites the HTML underneath React. After an add-to-cart, Shopify's Section Rendering API replaces whole sections with fresh Liquid HTML, and the theme editor reloads sections as it fires shopify:section:load and shopify:section:unload. AEM's Universal Editor edits the page in place while authors work. Consent banners, tag managers, app blocks and browser translation edit it too. React's virtual DOM assumes it alone controls its part of the page: its state is wiped or left pointing at elements that no longer exist, and outside edits end in removeChild errors. Browser translation breaking React this way is well known — which matters for a Korean storefront.
  4. These platforms do not run a build step. Edge Delivery blocks are plain JavaScript modules by design, with no framework and no build. Shopify serves theme assets exactly as uploaded; WordPress themes enqueue plain files. JSX and a store library mean a separate build pipeline whose output is pasted in, and which can fall out of step with the theme.
  5. Performance budgets, and duplicate runtimes. Shopify's Theme Store sets a Lighthouse performance minimum, and Edge Delivery is built to score 100. On Shopify, every app that ships React brings its own copy of React and ReactDOM, so one page can load several.
  6. The platforms' own answers are not React. WordPress's block editor is written in React, yet WordPress built the Interactivity API for front-end state — directives on server-rendered HTML, the same shape as petite-vue. Shopify keeps state on the server, in the Cart API and Liquid, and its reference theme Dawn uses Web Components. AEM's Edge Delivery uses vanilla blocks. All three keep state on the server and in the HTML — the newsletter form's rule, the DOM is the store.

A React island still has a place: one self-contained widget that owns its own <div>, shares no page-wide state, and asks the Worker for its data. Even there, an Elm island does the same job without a runtime that has to stay in step with the page.

In one line: a state manager wants to own the page, and theme platforms own the page. Keep durable state on the server, as Xano records the Worker returns; keep shared state in the page's own markup; and let each island keep only its own.

3.35.9 Choosing

  1. Start with where the decision must live, not the interface. If it can hurt someone or cost money, it goes in firmware (8), a sandbox (4) or behind the edge (1). The interface is chosen afterwards.
  2. Keep the JavaScript you have. Options 1, 2, 3 and 6 all keep a web UI. Moving decisions out of the page is the security win; rewriting the page is not.
  3. Use Javy before Rust for Shopify logic. The sandbox is identical, and the team already writes JavaScript. Move a function to Rust when instruction counts demand it.
  4. Reach for Elm for one hard widget, not a whole site. It earns its keep where state is complicated and drawing must not execute.
  5. Choose native Rust only when the browser is in the way — heavy rendering, device screens, offline tools. Its compiler guarantees end at the first network call, like everyone else's.
  6. Whatever you choose, pin the code and validate the data. A Content-Security-Policy on every web surface, typed schemas at every boundary, and nothing that crosses a boundary allowed to run.

3.35.10 Further viewing