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

  1. 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.
  2. Created; body written from the project's build records.