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.
#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:
#[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:
use cratePRIMARY_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.
use ;
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:
pub const TOKENS_CSS: &str = include_str!;
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.
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:
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:
ssr— native target. The view renders to an HTML string; no DOM involved.hydrate— wasm target. The view expects its DOM to already exist and attaches to it.csr— wasm target. The view builds its DOM from nothing. Used by the tests.
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:
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:
pub use 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:
pub const PRIMARY_NAV: = ;
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:
assert!;
assert!;
assert!;
assert!;
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:
for source in &sources
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:
let Some = window else ;
let Some = win.document else ;
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.
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:
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:
/* styles.css */ --);
/* article.css */ --);
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:
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:
format!
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:
let wasm = js.replace;
format!
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:
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:
let pct = .clamp;
bar.style.set_property;
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:
}
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.
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:
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:
- Which article routes get a copy button, and what's on the clipboard when you press one.
- How far down the page you are.
- Whether the diagnostics panel is open, and which project row is selected.
- Whether this is a cold or warm load.
- What the machine actually measured: boot time, transfer size, browser engine.
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:
,
,
and the browser overwrites them once it can actually measure:
if let Some = doc.get_element_by_id
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:
class:hidden=move || !matches
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
-
Leptos — fine-grained reactivity, server-side rendering, and the hydration model. https://leptos.dev/
-
Trunk — WASM web application bundler, build hooks, and asset pipeline. https://trunkrs.dev/
-
wasm-bindgenandweb-sys— the generated JavaScript/WebAssembly boundary. https://rustwasm.github.io/wasm-bindgen/ -
pulldown-cmarkandsyntect— build-time Markdown parsing and syntax highlighting. https://github.com/raphlinus/pulldown-cmark -
The Rust Reference — the
pathattribute and conditional compilation. https://doc.rust-lang.org/reference/items/modules.html
The ideas
-
Fabio Ellena, "Architecture Must Follow Pressure," 2026. https://fblln.github.io/articles/architecture-must-follow-pressure/
-
Fabio Ellena, "Performance Is Making Gradle Less Gradle," 2026. https://fblln.github.io/articles/performance-is-making-gradle-less-gradle/