FE/26
MENU

2026-08-08 · 21 min read

The Runtime Is What's Left Over

ArchitectureRustWebAssemblyBuild Systems

There are two websites here.

One is the portfolio: a single page, compiled from Rust to WebAssembly, that filters projects as you type and answers to keyboard shortcuts. The other is this — an article, one of a folder of plain HTML files produced by a completely separate program that has never heard of the portfolio and cannot call a single function inside it.

They have the same header. The same typeface, at the same weights, from the same self-hosted files. The same orange. The same hairline rules at the same opacity. Press S on either one and the same diagnostics panel slides open. Not "similar" — the same, in the strict sense that there is exactly one description of each of those things anywhere in the repository.

That is the part of this site I want to write down. Not "it's Rust," which is a material rather than a shape. The shape is what two independently built surfaces are allowed to share, where that shared thing physically lives, and what stops it from quietly becoming two things again.

TWO SURFACES · ONE SET OF AGREEMENTS PORTFOLIO ONE PAGE HYDRATED WASM src/lib.rs shared/ tokens.css typography.css header.css navigation.rs chrome.rs instruments.rs WRITING MANY PAGES STATIC HTML content/articles two different programs render these surfaces neither of them owns what they agree on
Six files in the middle column. That is the entire contract between the two halves of the site — three stylesheets, and three Rust files holding the navigation data, the chrome markup, and the registry of interactive instruments.

The parts, before any of the details

One view library. src/lib.rs holds the portfolio as a Leptos component tree. It is not a browser file. It is a library, compiled twice — once for your machine, once for WebAssembly — with different feature flags each time.

A native renderer. tools/site links that library on the host, asks it for HTML, and writes the result into the page Trunk produced.

A Markdown generator. tools/blog is a separate binary that never touches the portfolio view. It reads content/articles/*.md and writes the entire Writing section — article pages, tag indexes, an Atom feed, a sitemap, and the unlisted /lab/ page.

A React mirror. design-system/ reimplements the site's components in TypeScript so that designs built from it map onto the live site exactly. It is downstream by definition; the site is the source of truth.

And shared/, which belongs to none of them and ends up inside all of them.

Everything those parts produce lands in one directory, and that directory is the whole product. There is no runtime assembly step, because there is nothing at runtime to assemble it. GitHub Pages serves files.

What "shared" means here, precisely

For most of this site's life shared/ was not a crate. No Cargo.toml, no version, no dependency edge — just six files, which the programs that needed them reached across the directory tree and compiled in place:

rust
#[path = "../../../shared/chrome.rs"]
mod chrome;
#[path = "../../../shared/navigation.rs"]
mod navigation;

#[path] tells the Rust compiler where a module's source lives when it isn't where the module tree says it should be. And the obvious objection to it is wrong, so it needs saying precisely: nothing is copied. There is one chrome.rs on disk. Three programs compile it, none of them holds a duplicate, and no edit can land in one copy and miss another. Drift was already impossible.

So the argument for leaving it alone was a good one, and I made it for a long time. A shared crate makes sharing legible. It does not make it any more true.

What changed my mind was the second declaration in that snippet.

tools/site renders the portfolio's chrome. It never mentions navigation — not once, anywhere in the file. It declares that module because shared/chrome.rs contains this line:

rust
use crate::navigation::PRIMARY_NAV;

crate::, in a file included by path, means the consumer's crate root. So chrome.rs doesn't merely get compiled into its host — it makes demands of the host's module tree. Every program that wants a header has to know, and restate, what the header privately depends on. Nothing reports this; you get an unresolved-module error naming something you've never heard of.

That is the failure files cannot fix, and it compounds. Any shared file that grows a dependency edits every consumer.

So shared/ is a crate now: fblln-shared, no dependencies of its own, and callers name only what they actually use.

rust
use fblln_shared::{HEADER_CSS, TOKENS_CSS, TYPOGRAPHY_CSS, chrome, instruments};

The stylesheets came along with it. CSS has no module system to borrow, so the crate reads them at compile time and exports them as strings:

rust
pub const TOKENS_CSS: &str = include_str!("tokens.css");

include_str! pastes a file's contents into the binary as a string literal at build time — no I/O, no runtime cost, and a build failure if the file moves. That line used to be written out with a ../../../ prefix in every crate that wanted it. Now it exists once, and TOKENS_CSS is a name rather than a path.

ONE CRATE · THREE CONSUMERS, ONE OF THEM TEST-ONLY fblln-shared chrome navigation instruments 3 STYLESHEETS tools/site → NATIVE BINARY tools/blog → NATIVE BINARY fblln-portfolio → DEV-DEPENDENCY the registry ships zero bytes to a reader chrome’s private dependency stopped leaking
Each arrow is a separate compilation of the same source into a different target; the three programs never link against each other. The bottom one is a dev-dependency, because only a test reads the instrument registry — the shipped bundle dispatches on a DOM attribute and carries none of it.

Note what did not improve: correctness. One file was already one file, and no bug was ever fixed by this. What the crate bought is a private dependency staying private, and ../../../ ceasing to be load-bearing. That is a real but modest return — which is why it took a fourth include_str! appearing in a second crate before I thought it was worth the manifest.

The version I'd defend without hesitation is the smaller decision inside it: fblln-shared has no dependencies of its own, and I intend to keep it that way. Anything that needs a crate to express is not something both surfaces agree about. It is something one of them owns.

Why Rust, specifically

Every architecture note eventually has to justify its language, and "it's fast" is not the reason here — a personal site does not need a fast language. Four specific properties of Rust are what the design actually rests on.

One language on both sides of the build

The program that generates the HTML and the program that runs in your browser are the same language, and in the portfolio's case the same code. tools/site links the view library natively and asks it for a string:

rust
pub fn render_static_app() -> String {
    view! { <App /> }.to_html()
}

The browser build then attaches to exactly that markup. There is no JavaScript layer re-describing what the Rust already knows, because there is no second implementation to describe. The usual static-site arrangement — a generator in one language, an interactive layer in another — has a seam running straight through the middle of it, and every seam is a place where two things that were meant to be identical stop being identical.

Rust compiles to wasm32-unknown-unknown as a first-class target, which is what makes "same code, both sides" available at all rather than aspirational.

Conditional compilation removes code instead of skipping it

The same crate compiles under three mutually exclusive feature flags:

THREE MODES · MUTUALLY EXCLUSIVE BY CONSTRUCTION ONE CRATE src/ ssr · NATIVE · HTML STRING OUT hydrate · WASM · ATTACHES TO EXISTING DOM csr · WASM · BUILDS THE DOM · TESTS one source, three compilations the flag decides what the code even is
These are not runtime branches. An ssr build contains no DOM code at all — not disabled, not tree-shaken, simply never compiled — which is why the native renderer can link a browser application without dragging a browser into the build.

The finer-grained version of the same tool is what keeps the shipped bundle honest. Browser-only modules are gated on the target architecture:

rust
#[cfg(target_arch = "wasm32")]
mod boot;
#[cfg(any(target_arch = "wasm32", test))]
mod instrument;

And the instrument registry — the list of interactive figures and what each one demonstrates — is compiled into the blog generator, and into the test build of the library, and into the shipped bundle not at all:

rust
#[cfg(test)]
pub(crate) use fblln_shared::instruments as registry;

The browser doesn't need the list. It finds a data-instrument="sram" attribute in the DOM and dispatches on the string. So the registry costs bytes in the build tools, where bytes are free, and zero bytes in the thing you downloaded — which is also why fblln-shared is a dev-dependency of the portfolio crate rather than a dependency. A test asserts the dispatcher handles every registered slug, which is what stops the lab page from advertising a figure that never appears.

That is the shape of the whole argument for cfg: it lets one body of source code have genuinely different contents depending on where it's going, checked by the compiler rather than by a bundler's heuristics.

The shared data is checked, not stringly-typed

Navigation is a constant array of a struct, not a JSON file that both sides parse and hope about:

rust
pub const PRIMARY_NAV: [NavItem; 5] = [ … ];

The length is in the type. A missing field is a compile error in three programs at once. And because it's ordinary Rust, the invariants that actually matter get pinned by ordinary tests: destinations are unique, and the Writing link stays absolute so it doesn't resolve relative to whatever generated article route it landed on. That test doesn't check that navigation looks right. It checks the two specific ways it silently goes wrong.

CSS invariants can be unit tests

This is the trick I like most, and it falls out of include_str! for free. Because the stylesheets are string constants inside a Rust binary, the test suite can assert things about the CSS:

rust
assert!(TOKENS_CSS.contains(token));
assert!(!PORTFOLIO_CSS.contains(token), "{token} redefined in styles.css");
assert!(!READING_CSS.contains(token), "{token} redefined in article.css");
assert!(DESIGN_SYSTEM_TOKENS_CSS.contains("--line: rgba(10, 10, 10, 0.3);"));

A surface that redefines a shared token fails cargo test. The React design system drifting from the site's token values fails cargo test. Concatenation order — layout may specialise the base tokens, then the shared header reasserts its chrome — is asserted by finding each fragment's index in the output and checking they ascend. None of this needs a CSS toolchain, a linter, or a plugin. It needs the stylesheet to be a string in a language that already has a test runner.

The version of this I'd defend hardest reaches across a language boundary. The portfolio preloads its six font files from a hand-written list of <link> tags in the site generator — a copy, by hand, of the src URLs in shared/typography.css. Add a seventh weight to the stylesheet and forget the list and nothing breaks: the face still loads, one round trip later, after the CSS parses, with a flash of fallback text in between. That is exactly the class of bug that survives review forever. So the test derives one from the other:

rust
for source in &sources {
    assert!(
        CRITICAL_FONT_PRELOADS.contains(source),
        "{source} is declared but never preloaded"
    );
}

sources is parsed out of the stylesheet at test time. The stylesheet is the input to the assertion rather than a second thing to remember.

And what Rust makes worse

Honesty first: WebAssembly cannot touch the DOM. Every DOM call goes through a generated web-sys shim into JavaScript, and DOM objects are integer handles into a JavaScript-side table rather than anything living in linear memory. Which is why almost every line of browser code here is unwrapping something:

rust
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };

Five words of JavaScript, four lines of Rust. That is a genuine tax, paid on every interaction, and it is the reason the runtime surface is kept as small as it is — not only because bytes are scarce, but because this is the part of the codebase where Rust is at its least pleasant.

The CSS: one source, two delivery mechanisms

The three shared stylesheets are the tokens (colour, hairlines, spacing), the type contract (@font-face declarations and the font stacks), and the header. Both surfaces need all three. They receive them completely differently.

ONE STYLESHEET SOURCE · TWO WAYS OF ARRIVING shared/ tokens.css typography.css header.css TRUNK HASH · ESBUILD MINIFY INLINED <style> IN index.html include_str! · + SYNTECT THEME WRITTEN TO /articles/article.css the same declarations either way only the delivery differs
The portfolio is one document, so its critical CSS is worth inlining: GitHub Pages offers no Early Hints and no connection tuning, and on a cold navigation a handful of tiny stylesheet requests costs more in round trips than it saves in document bytes. Writing is many documents, so a single cacheable file wins instead.

On the portfolio side, Trunk treats each stylesheet as an asset, hashes it, esbuild minifies it, and then the site generator walks the final document, finds those <link> tags, and replaces them with the minified bytes inside a <style> element — adding font preloads at the same position, so the browser starts fetching the six woff2 faces at document discovery instead of after the CSS parses.

On the Writing side, the generator composes the file itself, in an order that matters:

rust
fn article_css(code_css: &str) -> String {
    format!("{TOKENS_CSS}\n{TYPOGRAPHY_CSS}\n{READING_CSS}\n{HEADER_CSS}\n{code_css}\n")
}

Tokens, then type, then Writing's own layout, then the shared header reasserting its chrome, then the syntax-highlighting theme. code_css is generated in the same run by syntect from a theme — the highlighter runs at build time, emits class names, and ships a stylesheet, so readers get coloured code with zero bytes of highlighter.

The bug that made the argument for me

The tokens were the last thing still duplicated, and that is where it broke. Each surface declared its own :root:

css
/* styles.css   */ --line: rgba(10, 10, 10, 0.3);
/* article.css  */ --line: rgba(10, 10, 10, 0.18);

Same name, different value, because a task to strengthen the portfolio's grid hairlines moved one of them and not the other. Nothing looked wrong on either page in isolation. But shared/header.css — one file, one rule — styles the mobile navigation with var(--line), so a single line of CSS rendered two different hairlines depending on which document it landed in. Shared CSS with unshared variables is not shared CSS. It is a template that two documents fill in differently.

The design system had already solved this properly, with --line and a separate --line-soft for exactly the lighter case Writing wanted. The site had simply never adopted the second name, so --line quietly meant two things.

One shared/tokens.css now, and the tests above. That is the shape of every fix in this section: not "be careful," but "make the careless version fail."

The markup: one builder, two callers

The header and the diagnostics panel used to exist twice — once as a Leptos view! with signals behind it, once as a format! string in the article generator. Both surfaces loaded the same stylesheet, which made them look identical enough that the duplication never announced itself.

Now there is one builder, and it returns a string:

rust
pub fn topbar(base: &str) -> String
pub fn system_panel(rows: &[(&str, &str, &str)], note: &str) -> String

The base parameter is the only real difference between the two surfaces. The portfolio passes "", because #work already targets the document it's in. Generated pages under /articles/ pass "/", so the same link navigates home rather than scrolling whichever page it landed on. One conditional, in one place, with a test asserting both variants.

Choosing a plain String over a Leptos component is what makes this work at all: a component would have dragged the framework into a Markdown generator that has no business knowing what Leptos is. A function that returns markup is the widest possible interface — anything that can write bytes can call it.

That decision has a structural consequence on the portfolio. The chrome is emitted outside the hydration root:

rust
format!("{}<body>{topbar}{BOOT}{NO_SCRIPT}<div id=\"app\">{app}</div>{panel}</body>{}", …)

Leptos hydration walks the existing DOM expecting to find exactly the structure its own view produces, including the marker comments it leaves for the purpose. Anything the browser build doesn't know about cannot be inside that cursor. So the shared chrome sits beside #app, gets its behaviour attached imperatively by the same module that attaches it on article pages, and the reactive graph never learns it exists. Removing that duplication was a net deletion: a component, two view blocks, four signals, two effects, and a copy of the panel built with innerHTML.

The bundle has two jobs

The same hashed WebAssembly file serves both surfaces. Article pages pull it in with a loader the generator writes itself:

rust
let wasm = js.replace(".js", "_bg.wasm");
format!("<script type=\"module\">import init from \"/{js}\";init({{module_or_path:\"/{wasm}\"}})</script>")

If no bundle is found — a manual generator run outside a Trunk build — the string is empty and the pages stay static. That is the honest test of progressive enhancement: the degraded path is not a fallback I wrote, it is what happens when I write nothing.

Once loaded, the entry point looks at where it is and takes one of two branches:

ONE BUNDLE · TWO ENTIRELY DIFFERENT JOBS init() ONE BUNDLE PATHNAME? /articles/ · /lab/ HYDRATE #app THE PORTFOLIO ENHANCE THE DOCUMENT COPY · PROGRESS · INSTRUMENTS article pages import the same hashed file no second bundle to keep in step
The branch is four lines and a string prefix. Everything downstream of it — the diagnostics panel, the keyboard handling for Esc, the byte measurements — is the same code reached from two directions.

On an article page the enhancement pass is small and entirely optional: a copy button dropped into each code block's language header, a reading-progress bar appended to the body, the diagnostics panel wired up, and any interactive instruments mounted into their placeholders.

The instruments are where the shared-code story pays off most directly. Each one keeps its simulation in a pure Rust module that compiles on every target, so cargo test covers the argument the figure is making natively, and the browser only ever renders it. The figure in an article is a <div data-instrument="…"> containing a static paragraph; the bundle clears it and mounts a Leptos component in its place, and if the bundle never arrives the paragraph is what you read.

The reading-progress bar is the whole philosophy in thirty lines. It doesn't exist in the served HTML; the wasm creates it, then updates one CSS property per scroll event:

rust
let pct = (scrolled / (full - viewport).max(1.0) * 100.0).clamp(0.0, 100.0);
bar.style().set_property("width", &format!("{pct}%"));

full - viewport is the real scrollable distance, so the bar reaches 100% at the bottom instead of short of it. .clamp absorbs iOS rubber-banding. And the smoothness people notice is not in that code at all — it's one line of CSS:

css
.reading-progress { transition: width 0.08s linear; }

Scroll events arrive in irregular bursts. An 80ms linear transition interpolates across the gaps, so the bar glides between samples instead of stepping. The Rust is a sampler; the smoothing is the browser's job, done in a line, for free. That is the shape of most good decisions in this codebase — the cheapest layer that can own a problem should own it.

The build that puts it together

Everything above happens before anyone requests a page. Trunk compiles the hydrate-mode bundle and emits the document shell; then one post-build hook runs three steps in a deliberate order.

THE BUILD · EVERY STAGE RUNS BEFORE A READER EXISTS TRUNK SHELL + WASM BLOG GEN MD → HTML ESBUILD MINIFY SITE GEN SSR BODY DIST ONE post_build HOOK · DELIBERATELY SERIAL trunk may run same-stage hooks concurrently so one shell script runs them in order
The ordering is a real dependency, not tidiness. The site generator inlines the CSS that minification just produced, so it has to run after it — and Trunk offers no ordering guarantee between hooks in the same stage, which is why they live in a single script instead of three hook entries.

Two details in that pipeline stand out, because both are cases of a build step protecting a promise the code makes.

The site generator replaces the body rather than appending to it, so repeated watch builds converge instead of accumulating. And it deletes one tag from the final document: Trunk emits a <link rel="preload" as="fetch"> for the WebAssembly module, and wasm-bindgen's loader later fetches the same URL itself with a different request mode, so the browser can't reuse the preloaded response and downloads the module twice. On a site whose whole premise is a byte budget, shipping the payload twice would have been a quiet joke at my own expense.

The blog generator does the equivalent for content: it deletes and recreates its own output subtree on every run. Moving an article into drafts/ should remove its route, its feed entry, and its tag page — not leave a public URL behind that nothing links to and nothing will ever clean up. A generator that only ever adds files is a generator that lies about what's published.

The budget that keeps it honest

CI gzips the release WebAssembly module and fails the build above 512000 bytes:

yaml
gzip -9 -c "$wasm_file" > /tmp/app.wasm.gz
bytes="$(wc -c < /tmp/app.wasm.gz)"
test "$bytes" -le 512000

A budget you can't fail is a wish. This one has teeth, so every runtime feature has to justify its bytes against features that already exist.

The release profile is tuned for size rather than speed — opt-level = "z", fat LTO, one codegen unit, panic = "abort", symbols stripped. panic = "abort" in particular deletes the unwinding machinery, which a browser page has no use for; a panic here is a bug I need to fix, not a condition I intend to recover from.

web-sys is opted into a binding at a time. The crate covers essentially the whole web platform, and Cargo.toml lists only what's reachable: Clipboard, CssStyleDeclaration, NodeList, PerformanceResourceTiming, and a dozen more. Adding a feature is a visible line in a diff, which is exactly the friction it should have.

The value of the gate is not the number. It's that a size regression fails a pull request the same way a broken test does, so "I'll trim it later" is not an available move. Later is a place where nobody trims anything.

What's actually left for the browser to decide

Here is the entire list of things this site decides at runtime:

  1. Which article routes get a copy button, and what's on the clipboard when you press one.
  2. How far down the page you are.
  3. Whether the diagnostics panel is open, and which project row is selected.
  4. Whether this is a cold or warm load.
  5. What the machine actually measured: boot time, transfer size, browser engine.
WHAT IS DECIDED WHEN BUILD TIME HTML · HIGHLIGHTING · TOC · FEED · SITEMAP · CSS · ROUTES RUNTIME SCROLL POSITION · CLIPBOARD · CACHE STATE the widths are not a measurement the asymmetry is the design
Anything on the top row is computed once, by me, and served as bytes. Anything on the bottom row genuinely cannot be known until a specific person is looking at a specific viewport — which is the only admission ticket to that row.

That last item is the interesting one, because it collides with hydration. The page states its boot time, the transfer size of its own WebAssembly, and the browser engine it's running on — and a build has no way to know any of the three. Meanwhile the rule hydration imposes is absolute:

The static render and the first hydrated render must produce byte-identical output.

So the build writes down deterministic lies:

rust
("fact-engine", "BROWSER ENGINE", "Browser VM"),
("fact-boot", "BOOT TO WASM ENTRY", "STATIC"),

and the browser overwrites them once it can actually measure:

rust
if let Some(cell) = doc.get_element_by_id(&id) {
    cell.set_text_content(Some(&value));
}

Writing to a hydrated node from outside the framework sounds like precisely the thing you're not supposed to do, and it's safe here for a specific reason: no signal owns those nodes. Both builds emit the same placeholder, hydration matches, and only then does the measurement land. Had those values been reactive and initialised from the browser, the hydrating build would have produced different text than the HTML it was attaching to.

The pattern generalises, and it is the same line every other section of this article has been drawing: anything genuinely browser-dependent enters after the build has finished, never during it.

Filtering, and why the fallback isn't a fallback

One more consequence, because it's the clearest demonstration that the two surfaces really do behave the same way.

The interactive portfolio holds four signals — category filter, search text, keyboard cursor, which case study is open. That is the complete list of what the page can be in a state about. Projects are never added or removed from the DOM:

rust
class:hidden=move || !projects::matches(project, &category.get(), &query.get())

Every project is in the server-rendered HTML permanently, and searching toggles a class. Leptos is fine-grained reactive — no virtual DOM, no diffing pass, the view! macro compiles into code that creates real nodes and subscriptions that update exactly the node they belong to — so there is no reconciliation algorithm in the bundle, because nothing reconciles.

Which means the no-JavaScript version of the portfolio contains every project rather than an empty container waiting to be filled. The accessible fallback isn't a fallback I built. It's the same DOM with nothing hiding anything — which is exactly what an article page is, too.

What the architecture actually is

Not "Rust and WebAssembly." That's the material.

The shape is two independently generated surfaces with a deliberately tiny, compiler-enforced contract between them: three stylesheets, three data files, one bundle, and a set of tests whose only job is to fail when a copy appears where there should be one original. Rust earns its place not by being fast but by being the same language on both sides of the build, by removing code at compile time rather than skipping it at runtime, and by making a stylesheet something a test can hold an opinion about.

And once everything that can be settled before a reader arrives has been — content, structure, highlighting, routing, navigation, metadata, styling, the markup of the header itself — what remains is a genuinely short list of things that depend on a particular person, on a particular viewport, at a particular moment.

The runtime is not the point of the system. The runtime is the residue after the build has taken everything it can.

I did not arrive at that by taste. A host with no server and a budget with real teeth removed the alternatives one at a time, which in my experience is where most durable architecture comes from. The constraint decides; you just find out what it decided.

References

The stack

  1. Leptos — fine-grained reactivity, server-side rendering, and the hydration model. https://leptos.dev/

  2. Trunk — WASM web application bundler, build hooks, and asset pipeline. https://trunkrs.dev/

  3. wasm-bindgen and web-sys — the generated JavaScript/WebAssembly boundary. https://rustwasm.github.io/wasm-bindgen/

  4. pulldown-cmark and syntect — build-time Markdown parsing and syntax highlighting. https://github.com/raphlinus/pulldown-cmark

  5. The Rust Reference — the path attribute and conditional compilation. https://doc.rust-lang.org/reference/items/modules.html

The ideas

  1. Fabio Ellena, "Architecture Must Follow Pressure," 2026. https://fblln.github.io/articles/architecture-must-follow-pressure/

  2. Fabio Ellena, "Performance Is Making Gradle Less Gradle," 2026. https://fblln.github.io/articles/performance-is-making-gradle-less-gradle/