A widget in someone else's page
Embedding a widget in a page you don’t control inverts the usual frontend assumptions: the host’s CSS is hostile, the platform dictates your directory structure, and your bundle ships as a committed artifact. This log covers a storefront bookings widget — Preact in a Shadow DOM, the front door to the bookings system that runs on SQLite — and the isolation lessons it enforced.
The platform dictates your directory structure
The widget ships as a Shopify theme app extension, and the platform is
strict about what an extension may contain: exactly four directories —
assets, blocks, snippets, locales — plus one config file. Put a
src/ folder or a package.json inside and the dev server refuses to
start. So the widget’s source can’t live where the widget lives. The build
source sits in a top-level widget-src/ directory (the platform only scans
extensions/), and esbuild emits a single IIFE bundle into the extension’s
assets/ folder. There is no theme-side build step — the platform serves
assets byte-for-byte as uploaded — which leads directly to the next
constraint: the bundle is a committed build artifact, checked into git next
to source that generates it.
A committed bundle needs a freshness gate
Committed artifacts drift. Someone edits a source file, forgets the build
step, and the repo now lies — the diff shows a fix the storefront will
never serve. The countermeasure is a CI step that rebuilds the bundle and
runs git diff --exit-code against the committed copy: any widget source
change that isn’t accompanied by its regenerated bundle fails the build.
That check earns its place on every change, because “rebuild and commit the
output” is exactly the kind of two-step ritual that survives three weeks on
goodwill and then quietly stops happening. The intermediate compiled CSS
stays gitignored; only the final bundle is tracked.
Shadow DOM makes the host’s CSS someone else’s problem
A storefront theme is an arbitrary pile of third-party CSS that changes
without notice, so the widget renders into a Shadow DOM root: theme styles
can’t leak in, widget styles can’t leak out. Inside the boundary, Tailwind
is compiled and the output inlined into the shadow root — no external
stylesheet request, no dependence on load order. The one production-grade
gotcha: Tailwind’s content glob must include the ts,tsx extensions the
widget is actually written in, or the compiler finds zero class names and
emits preflight only — and the widget ships to the storefront unstyled.
That’s a silent failure mode: the build succeeds, the bundle is fresh, and
the result is a booking dialog rendered in browser-default styling.
Fonts are the hole in the shadow boundary
The one thing that couldn’t stay inside the boundary was typography.
@font-face declared inside a shadow root is unreliable across browsers —
font loading is effectively a document-level concern. The fix is the
widget’s single deliberate leak: on mount it injects a <link> for its two
brand typefaces into document.head, idempotently, so two widget instances
on one page don’t inject twice. Isolation absolutism would have meant
system fonts; the honest design is a boundary with one documented,
idempotent exception.
Two frameworks in one repo, kept apart by tooling
The host app is React; the widget is Preact, chosen because a storefront
embed pays for its bundle size on someone else’s page. Both live in one
repo without touching: the root TypeScript config excludes widget-src
entirely, the widget carries its own nested ESLint config, and the icon
library is routed through preact/compat with an esbuild alias plus
matching tsconfig paths. The booking flow itself runs in a native
<dialog> element — two entry buttons, a two-step picker, then redirect to
a deposit invoice — and the date/slot logic lives in plain TypeScript
modules under core/, unit-tested with no DOM at all. The pattern that
generalises: a page you don’t control is an untrusted runtime, so every
assumption a normal app gets for free — cascade, fonts, build pipeline,
framework presence — has to be replaced by an explicit decision to isolate,
inline, inject, or commit.
Revisions
- Published, one day after its 2026-09-03 slot in the content plan. Cross-linked the SQLite bookings post, which covers the same system's backend.
- Created; body written from the project's build records.