Second post from the same pilot as The descriptor survived, const did not. That one asked whether a design pattern ported from React to Rust. This one asks what happens when you try to ship the result. Topcoat's own roadmap still has "Docs for how to deploy Topcoat" unchecked, so this is a report from unsolved ground rather than a walkthrough of a documented path. Everything below was measured on an open probe crate you can clone and re-run.
The descriptor pilot ends with a fact that sounds like a conclusion. It compiles to a single 10 MB binary, that binary runs from an empty directory, and the pages it serves contain no <link> and no <script> at all. I verified each of those before writing them down.
They are also very close to meaningless, and it took me longer than it should have to see why. The pilot's two screens use no styling framework, no icons, no web fonts and no client-side JavaScript, so there was never anything for a build to fetch or a deployment to carry. Reporting that as "Topcoat deploys as a single binary" would have been a claim about a test nobody ran — technically accurate, and false in every way that matters to somebody deciding whether to use it.
So I built a second crate with those parts switched on, and measured what changed. The answer is more interesting than the headline, and the part I got wrong along the way is the part worth reading.
The declaration that does not declare everything
This blog keeps circling one idea, and it is worth naming before the details bury it. One skeleton, many screens argued for declaring screens as data rather than coding them. The descriptor post found that the declaration held for values and broke at structure, and cost one enum variant to fix.
Build systems are the same problem in a less obvious place. A Cargo.toml is a declaration of what a project depends on, and the entire promise of a lockfile is that the declaration is complete — that if you fetch what it names, you have what you need. Data engineering runs on the same assumption every day, because a pipeline you cannot rebuild from its declared inputs is a pipeline whose output you cannot vouch for. Reproducibility, provenance, and the ability to answer where did this artefact come from all rest on the manifest being honest about its dependencies.
Which is why the first thing worth testing is not build speed. It is whether the declaration tells the truth.
No Node, and four build-time downloads in its place
Topcoat's Tailwind integration describes itself as "a thin Rust wrapper around the standalone Tailwind CSS CLI" that "does not run Node, PostCSS, or a Vite-style asset pipeline." That holds, and the repository backs it up: the crate has no package.json, nothing resembling node_modules, and no second lockfile sitting beside Cargo.lock. For anyone who has maintained a pipeline where the Rust build and the frontend build are two separate caches with two separate failure modes, this is a real simplification and I do not want to undersell it.
But npm does not disappear so much as change form. What replaces it is build-time HTTP, through four documented paths:
| Mechanism | Fetches | Cached in |
|---|---|---|
| Tailwind | the standalone CLI binary, from GitHub | target/topcoat/cache/tailwind |
| Icons | icon-set JSON per set, via build.rs |
target/topcoat/cache or a directory you choose |
asset!(url) |
JavaScript from a CDN, vendored at build time | target/topcoat/cache/assets |
| Fonts | woff2 files from Fontsource | as above |
Each of those is a deliberate, documented design decision rather than an oversight. But each one also moves a dependency out of the manifest and into a build script, and that move has three consequences: the lockfile no longer describes it, nothing pins the version that arrives, and cargo fetch will not retrieve it. Four dependencies that a Rust developer would expect Cargo.lock to govern are governed by something else instead. That is the trade, and the next section is where it stops being theoretical.
What --offline does not cover
The normal Cargo contract is that cargo fetch retrieves everything the network is needed for, after which cargo build --offline succeeds. On a cold cache, that does not hold here:
$ cargo fetch # succeeds
$ cargo build --offline # with the network blocked
error: failed to run custom build command for `deploy-probe`
panicked at build.rs:
called `Result::unwrap()` on an `Err` value:
Http(Io(Custom { kind: ConnectionRefused, error: "Connection refused" }))--offline governs the crate registry. It does not govern build scripts, which are ordinary programs that may do whatever they like, including opening sockets. Any hermetic build — one deliberately cut off from the network so that its output depends only on its declared inputs — fails here until the Tailwind CLI is supplied rather than downloaded. Topcoat provides exactly that switch, through BuildConfig::executable("tailwindcss") to resolve it from PATH, or executable_env("TAILWIND_CLI") to read a path from the environment. The documentation is unambiguous about the effect: "A user-provided executable is used as-is: no download happens and no network access is needed." That is the setting a serious pipeline wants, and it is not the default.
Worth noting separately, because it is the sharper edge: version_checksum("4.3.2", "sha256:…") exists and is also not on by default. Out of the box, a build downloads a binary from GitHub and executes it without verifying what arrived.
The correction
Here is where I had it wrong, and the shape of the error is more useful than the finding.
I concluded from the failure above that the build downloads assets, wrote that down, and moved on. It is a reasonable reading of a build script panicking on a connection error, and it is not what happens. When I later cleared the asset cache and blocked the network properly, cargo build completed without complaint. The step that failed was the bundler:
$ topcoat asset bundle
failed to bundle assets: failed to download asset from
https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js: io: Connection refused
$ echo $?
1So there are two network stages, not one, and only one of them is cargo. The yellow boxes labelled network below are the three moments a build reaches out: two of them hang off build.rs, and the third off the bundler, which runs as a separate command afterwards.
asset!(url) does not download at compile time. It records the URL and a content hash, and the bundler resolves it afterwards by scanning the compiled binary. That distinction matters to a pipeline, because it means the build and the fetch can be isolated from one another, and because the failure lands in a different job with a different error.
The reason I believed the wrong version for as long as I did is worth flagging for anyone testing similar ground. The asset cache lives at target/topcoat/cache/assets, which sits outside Cargo's fingerprint tracking, so cargo clean -p does not remove it. Several builds that looked offline-clean to me were quietly living off that directory. A cache you forget about will confirm whatever you already believe.
The artefact is a binary and a directory
topcoat asset bundle writes its output next to the executable:
target/release/assets/
htmx.min-71ea67185bfa8c98.js 51 KB
latin-400-normal-ead637fd0b6b887d.woff2 13 KB
latin-600-normal-edde7cc4e2719898.woff2 13 KB
tailwind-59a9db92df193729.css 18 KB
manifest.tomlEvery filename carries a content hash — a fingerprint of the bytes, so a changed file gets a changed name and can therefore be cached by browsers indefinitely without risking a stale copy. The deployable unit is a 4.8 MB release binary plus 108 KB of assets, and the two must come from the same build; the documentation is explicit that a mismatch panics at render time.
I tested the halves separately by copying files into an empty directory:
| Shipped | Result |
|---|---|
| binary alone | startup panic — no asset bundle at …/assets |
binary + assets/ |
HTTP 200, everything served from /_topcoat/assets/… |
There is an alternative worth knowing about, where assets are hosted externally and only the manifest is compiled in, via AssetConfig::hosted_at(...) with include_str!. That does give you a genuine single binary, at the cost of somewhere to upload to.
Three ways the assets can be wired, and when each fails
Two things have to be true for assets to work: the router has to be given the bundle with .assets(...), and the bundle directory has to actually be next to the binary. Three of the four combinations are worth naming, and the reason this belongs in a deploy check rather than a build check is that they do not fail equally loudly.
asset!used,.assets(...)never wired into the router. It compiles, it starts, and then it panics on the first request that renders an asset, returning a 500. A deploy check that only asks whether the process came up records this as a success..assets(...)wired, bundle missing. It fails at startup, naming the directory it wanted. This is the good failure: early enough to stop a release, specific enough to fix without investigation.- Both present. It works.
The first is the one to design against, and it has a direct consequence for the smoke test. Checking that the port is open proves nothing. The test has to fetch a page that renders an asset, and then fetch the assets that page references:
- name: Smoke test — fetch a page, then its assets
run: |
cd dist
PORT=8080 ./deploy-probe &
for i in $(seq 1 30); do
curl -sf -o page.html http://127.0.0.1:8080/ && break
sleep 1
done
test -s page.html || { echo "page never served"; exit 1; }
grep -q '_topcoat/assets' page.html \
|| { echo "page rendered no asset URLs — bundle not wired"; exit 1; }
for u in $(grep -oE '(src|href)="/[^"]*"' page.html \
| sed 's/.*="//;s/"//' | sort -u); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080$u")
test "$code" = "200" || exit 1
doneOne more detail that decides whether a failed bundle is dangerous. When the download above failed, it left the generated CSS in assets/ but no manifest.toml. Since the loader needs the manifest, a pipeline that ignores the exit code gets state two — a startup failure — rather than a half-populated deployment that boots and serves broken pages. The exit code is still the thing to trust, but the fallback is the safe one.
Two integrations, opposite defaults
The most transferable finding has nothing to do with Rust. These two are not an arbitrary pair to test: Topcoat's README names them in a single sentence as the integrations it ships — "utilities for web fonts and icons, as well as easy integrations for Fontsource (Google Fonts) and Iconify." They solve the same problem, download from a catalogue at build time, and land in the same bundle. Every default in them still points the other way.
Fontsource ships everything unless you stop it. The component library's theme asks for the Geist typeface and does not bundle it, so a themed project needs a font declaration. Left at its default, with none of the narrowing arguments, that declaration ships every weight and style the family has:
| Declaration | woff2 files | assets/ |
|---|---|---|
fontsource_font!(GEIST, host: Asset) |
18 — nine weights, roman and italic | 364 KB |
weight: [400, 600], style: Normal, subset: Latin |
2 | 108 KB |
One line took the deployable from 68 KB of assets to 364 KB, for a page that renders two weights of Latin text. The narrowing arguments are documented, and the documentation even says "every combination of weight, style, and subset is a separate font file, so only include what you use." But nothing enforces that advice: the build does not warn, and the difference is visible only if you list the bundle. host: Asset is a separate and genuinely good decision — without it the stylesheet points at a public CDN and every visitor's browser fetches from there at runtime.
Iconify inverts all of it. Icons compile to inline <svg> in the HTML, so they stay out of the bundle entirely — there is no file for a browser to fetch, and no content hash to keep in step. Better, naming a whole catalogue is nearly free. Material Design Icons is roughly 7 500 icons in a 3.0 MB JSON file; iconify::include!("mdi") expands the set to constants, and the compiler discards the ones you never mention. Measured in release, with and without one icon actually rendered:
| Build | Bytes |
|---|---|
| the set included, no icon rendered | 4 871 936 |
| the same, one icon rendered | 4 878 400 |
6 464 bytes. The set behaves as a catalogue you index into rather than a payload you carry.
Iconify also has the vendoring story the rest of the build lacks. Pointing cache_dir at a directory in the repository and pinning the set with icon_set_version gives an icon build that is genuinely offline and reproducible, exactly as documented: "Files you place there yourself are used as-is." A wrong pin fails at build time with a 404 naming the URL, rather than drifting silently.
So within one framework, one integration defaults to shipping twelve times what you asked for and the other defaults to shipping only what you used. Neither is a bug. Both are defensible readings of what a developer wants. The lesson I would carry to any stack is that default and sensible are independent properties, and the only way to tell them apart is to list what actually shipped.
The component library hands you the code, and the merge
Topcoat UI is described as "premade components you can edit — a component library based on Tailwind inspired by shadcn/ui." That is accurate, and the word doing the work is edit. Running topcoat ui add button does not add a dependency; it copies source into the repository and records it:
[registries.topcoat.components.button]
hash = "sha256:ba4ab32d87e4c9a982faf657258edd8cf2a0da904ece5976e70185eeb5808f57"
file = "src/components/button.rs"Two consequences for a pipeline. Because the components are committed source, CI never runs ui add — it compiles them like any other module, so the CLI is a developer tool here rather than a build step, though asset bundle still keeps it in the pipeline. And that recorded hash is not checked by anything: there is no check, diff or update subcommand, ui list reports a hand-edited component as installed either way, and re-adding refuses on the file existing rather than on its contents. Upgrading means overwriting and losing your edits, or diffing by hand.
That is the trade stated honestly — you own the code, so you own the merge — and the hash is enough to write the drift report yourself. Ours prints what has diverged without failing the build, because editing components is the entire point of the model:
state = tomllib.loads(pathlib.Path("components.toml").read_text())
for registry in state.get("registries", {}).values():
for name, meta in registry.get("components", {}).items():
path = pathlib.Path(meta["file"])
actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
print(name, path, "=" if actual == meta["hash"] else "edited locally")A smaller thing that will still bite on day one: a freshly added component exports helpers your application may never call, so cargo clippy -- -D warnings fails on code you did not write.
The check I did not think to write
Everything above is a deployment failure, and every one of them can be caught mechanically. The workflow that came out of this work runs formatting, linting with warnings denied, a release build, the asset bundle, the component drift report, and the smoke test that fetches a page and then every asset it references. Each step carries a comment saying which finding above put it there, so it reads as the argument of this post in executable form.
All of it passed on a page that was visibly broken.

Left: the state every check passed on. Right: the same page after one line joined the theme's tokens, which changes the background too, because both now come from the theme.
The probe's main button rendered dark grey on dark navy — present, correctly positioned, and obviously wrong to anyone looking at it. Sampling the pixels afterwards put that button at 1.36:1 against its own page. Contrast ratios run from 1:1, meaning two identical colours, to 21:1 for black on white, and the accessibility guidelines most of Europe now writes into law — WCAG 2.1 — ask for at least 3:1 between a control and what sits behind it, so the control can be found at all. Its label, meanwhile, sat at 12.56:1. That gap is the whole problem: the text stayed perfectly readable, so the result looked deliberate rather than broken. The cause is that I had painted the page with Tailwind's stock palette while the component theme declares its dark values behind @custom-variant dark (&:is(.dark *)), which applies only when an ancestor carries the dark class — and no element on my page carried it, <html> included. So the button asked for bg-primary, received the light theme's value, and styled itself for a white page that was not there.
The fix is one line of joining the theme's own tokens rather than hand-picking colours:
<html class="dark">
<body class="bg-background text-foreground">Nothing in my pipeline warned. Both sets of class names are valid Tailwind, both reach the generated stylesheet, and the build had no way to know which background I intended — the contrast came out poor rather than absent. It took someone opening the page and looking at it.
That is not the same as saying no tool could have caught it, and it is worth being precise about why the obvious ones would not have.
Accessibility scanners check the wrong half. axe-core, which most of them are built on, publishes exactly two contrast rules, and both compare text against its background. Its rule list has nothing for WCAG 1.4.11, the non-text contrast this button failed. So a scan would have measured the label at 12.56:1, passed it, and said nothing at all about the fill behind it.
Visual regression needs something to regress from. Playwright's screenshot comparison, or a hosted equivalent, flags a render that differs from an approved baseline. This was the first render — the broken state was the only state that had ever existed, so there was no earlier image to differ from and nothing to flag.
A check that works is short, and it now runs. It asks the browser for each control's background and for the first non-transparent background behind it, computes the ratio between them, and fails anything under 3:1. That is about forty lines, added to the pipeline after this section was drafted — it catches the button at 1.36:1 and exits non-zero, which is what should have happened the first time.
Writing it produced one more instance of the thing this whole section is about. Its first working version reported 1.00:1 on the page that was fine. The theme declares its colours in oklch(), a CSS colour space newer than the familiar rgb(), and the browser hands them back the same way — so the button's fill came out as oklch(0.92 0.008 260). My first version read the three numbers in that as red, green and blue, which is a confident wrong answer rather than a crash. It now paints each colour into a canvas and reads the pixel back, letting the browser do the conversion instead of guessing at the format. So I ran it against both states before trusting it: 1.36:1 failing, 15.08:1 passing, matching a second measurement taken from screenshot pixels.
The cost is the part worth stating. The smoke test above is curl, which will tell you a page returned 200 but not what colour anything came out, so measuring rendered contrast means a headless browser. Checking that a no-Node app renders correctly put Node back in the pipeline — a package.json, a lockfile, and a Chromium download on every run. None of it goes near the crate or the deployable, and the framework's claim survives intact: it is the application that needs no Node. But the pipeline that verifies the application now does, and that is a cost the claim never covered.
So the conclusion is not automated checks are not enough, look at your app. The checks I had were not weak; they were precisely scoped, and each covered exactly what it claimed to:
cargo fmt --checkchecked formatting.clippy -D warningschecked for lints, and found a real one in a copied component.- The bundle step checked that every declared asset could be fetched and written.
- The smoke test checked that a page renders and its assets resolve, which they did.
Not one of them claimed to check whether the page looked right, and the green run misled me because I heard a broader guarantee than it offered. There is a fifth check now and it would have caught this one — but it exists because somebody opened the page and looked.
That is the same failure the descriptor post ran into from the other direction. There, a refactor was validated by capturing every screen's HTML before and after and diffing the two, and the run came back with zero differences — while the code underneath had acquired a global holding the request's locale, which meant two screens differing only by language could no longer exist at the same time. The rendered output really was identical. The test compared what a reader sees and never what it cost to produce, so a green run proved the global was invisible to the diff rather than acceptable. A green check answers the question it was written to ask. The risk is never that it lies — it is that we hear a bigger question being answered.
The take-home
Five things I would tell anyone putting a Topcoat application into a pipeline, and four of them generalise well beyond it.
Pin the CLI to the library version. topcoat asset bundle comes from a separately installed topcoat-cli, and an unpinned cargo install resolved to a version requiring a newer compiler than the project's. A pipeline that installs the latest CLI against a pinned toolchain breaks on someone else's release schedule.
Cache target/topcoat explicitly, with the platform in the key. The standard Rust caching action keys on Cargo state and knows nothing about that directory, so without it the 81 MB Tailwind download repeats on every run. The cached binary is platform-tagged, so a developer's macOS cache is useless to a Linux runner. Those two together are six lines and a pinned install:
- uses: Swatinem/rust-cache@v2 # Cargo state only — see below
with:
workspaces: deploy-probe
# The Tailwind CLI, the staged icon sets and the bundler's asset cache all
# live here, and rust-cache knows about none of them. The key carries the
# runner OS and arch because the cached CLI is platform-tagged:
# tailwindcss-4.3.2-linux-x64 here, macos-arm64 on a developer's machine.
- uses: actions/cache@v4
with:
path: |
deploy-probe/target/topcoat
deploy-probe/icons
key: topcoat-${{ runner.os }}-${{ runner.arch }}-tailwind-4.3.2-mdi-1.2.3
# Pinned deliberately. An unpinned `cargo install topcoat-cli` resolved to a
# version requiring a newer rustc than this project pins, so the build broke
# on someone else's release schedule rather than on a change of ours.
- run: cargo install topcoat-cli --version 0.6.2 --lockedAssume the manifest is incomplete and test that assumption. cargo fetch followed by an offline build is the cheapest possible check on whether your declared inputs are your real inputs, and it costs one CI job. Ours failed, which was the most valuable ten seconds in the whole exercise.
List what shipped, at least once. Not what the build said it did, and not what the framework documents — the actual files in the actual artefact. That is how a font declaration ballooned a deployment fivefold in one line, and nothing in the pipeline was looking for it.
Have something look at the rendered page. Everything above checks that the build produced what it declared; none of it checks that the result is usable. Closing that gap means a headless browser in CI, which is a real cost for a project whose appeal is not needing one — and the reason to pay it is in the title. A green pipeline and a broken page look identical from the outside.
Topcoat's deploy documentation is still unwritten, and having worked through the ground it would cover, I think the omission is honest rather than neglectful: the shape is genuinely more complicated than cargo build and copy the binary, and writing it down badly would be worse than leaving it open. What the probe produced instead is the notes this post was written from — every finding here with the measurement and the commands behind it, including the two I got wrong first. Offering those to the maintainers as a starting point is worth more than the post is, and it is the next thing I intend to do with them.
Built with an AI pair, worth repeating from the companion post because Topcoat's first release was April 2026 and the documentation is most of the corpus: the measurements and the failures are mine, the scaffolding was not. If you have deployed a Topcoat app and found the asset story simpler than this — or think a Chromium download is far too much machinery to buy one contrast rule, which is a reasonable position I argued myself for about an hour — I would like to hear it. The door is open at cordata.tech/contact. Related reading: The descriptor survived, const did not is the first half of the same pilot, and A pipeline is a descriptor, not a program makes the argument about declarations one domain over, where the thing being declared is data rather than a build.