Three ways the compiled-in viewer could be wrong, none of which anything would
have reported. Found by mutation-testing the guard added below — it failed
when it should have passed, and the reason was the second bullet.
* `include_dir!` registers NO rebuild dependency. Run `npm run build` in
frontend/export-viewer, then `cargo build`, and cargo sees no source change
and reuses the cached binary — carrying the PREVIOUS index.html. The file on
disk and the file in the binary disagree, git is clean, every check passes,
and Memories.zip ships a stale viewer. Confirmed empirically: after replacing
the artifact the compiled-in copy did not change until a source file was
touched. A build.rs now declares `rerun-if-changed` for
`static/export-viewer` AND `migrations` — sqlx::migrate!() embeds its
directory the same way, and there the stale snapshot is worse still: the
binary boots against a database that already ran a newer migration and
crash-loops with VersionMissing.
* `emptyOutDir: true` deleted the committed artifact BEFORE generating. That
was safe while the build could not fail; it no longer is, because
`inlineThemeFonts` now calls `this.error` on a keepsake that is not
self-contained. A failed build left the directory empty — and include_dir!
over an empty directory compiles fine, while `write_viewer_with_data`
iterates zero files and returns Ok. The result is a valid archive with every
photo and no viewer. The output is one overwritten file, so nothing
accumulates without the wipe.
* Nothing asserted the viewer was there at all. Now asserted at the point of
use (bail rather than write a viewer-less keepsake) and in a test that checks
presence, plausible size, and that no `url(/...)` survived inlining — the
three ways it can be present but useless.
The Dockerfile copies build.rs with the sources rather than with Cargo.toml, so
the dependency-cache layer stays byte-identical and the dummy build does not
run it.
The font-inlining guard only caught a RENAME. It asked "did /fonts/<listed
family>.woff2 disappear?", so an ADDITION walked straight past it — and an
addition is the likelier accident: someone doing ordinary app work adds a
display font or a decorative background to the shared theme, has no reason to
open a viewer build config, and ships a keepsake that reaches for
/fonts/Playfair.woff2 on the guest's own disk. font-display: swap hides it, so
the artifact looks right to everyone who happens to have the file locally and
renders in Times New Roman for the couple.
It now asserts the invariant instead of a list: nothing in the emitted
keepsake may reference an external URL. Self-maintaining, and it covers fonts,
images and stylesheets alike. In writeBundle rather than generateBundle —
generateBundle runs more than once and the stylesheet is not inlined on the
earlier pass, so asserting there fails a perfectly good build.
viewer-no-broken-tiles gets a positive anchor. Its "nothing is broken" check
filters img elements, so a viewer that rendered NOTHING yields [] and passes:
the one spec whose whole subject is that the images resolve was the one that
would have stayed green through a total viewer regression. Everything else it
checks comes from the backend and the classic head script, neither of which
needs the viewer bundle to have run.
And a CI job, because neither of the above fires on its own: no workflow,
Dockerfile or script built this viewer, so the guard could sit disarmed
indefinitely, and the committed artifact — compiled into the binary with
include_dir! — could drift from its source with nothing to say so.
The viewer inherits `src: url('/fonts/Inter.woff2')` from the shared theme CSS. That is
correct for the app, which serves `static/fonts/` from the site root — but the keepsake is
opened from file:// off a USB stick or a Downloads folder, where `/fonts/...` resolves to
the root of the guest's DISK. Both requests 404, and `font-display: swap` makes it silent:
the viewer renders in a fallback system font with nothing server-side able to report it.
Found by opening a real released keepsake in a browser and watching `requestfailed` — no
other signal exists, which is the recurring lesson about this artifact.
Fixing it in the shared CSS would inline ~154 KB into every app page load for nothing, and
shipping a `fonts/` folder beside index.html gives the guest a directory they can break by
moving one file. So the substitution belongs in the build that knows its output has no
origin. The plugin errors the build if the theme ever stops referencing those URLs, rather
than silently shipping another keepsake in Times New Roman.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the
extracted index.html is opened over file://. Browsers block external ES-module
scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows
a blank window. Build the viewer as one self-contained index.html (inlined
JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject
the data as window.__EXPORT_DATA__; the backend embeds the built viewer via
include_dir! and writes the data global into index.html when zipping.
Also surface the guest-facing download: a feed banner and the BottomNav Export
tab, both shown once the host releases the export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>