Architecture
xray captures the real rendered DOM of components during normal browsing of the dev server and renders faithful skeleton loading states from those captures. Placeholders are captured rather than hand-drawn, with no separate headless browser and no fixed-height or absolute-positioning hacks.
The system is two halves joined by one artifact:
- Capture (dev only) turns a live component subtree into a committed
plates/<name>.jsonfile — the Plate. - Render (dev and production) turns that file into the skeleton on the page.
Everything else — the Vite plugin, the HUD, theming, validation — serves one of
those two halves. The why behind each decision lives in docs/adr;
the language lives in the glossary; what’s next lives in the
roadmap.
xray is a from-scratch alternative to
boneyard, departing from it on two points.
boneyard stores flat {x,y,w,h} rectangles and replays them
absolutely-positioned, which forces a hardcoded container height and drifts from
the real layout; xray captures structural DOM plus scoped CSS, so the skeleton
reflows like the real thing. And boneyard drives a separate headless or
CDP-attached browser; xray captures from the browser session already in use.
Capture: from live DOM to the Plate on disk
Section titled “Capture: from live DOM to the Plate on disk”flowchart TD
ready["<Skeleton> renders real content<br/>(Readiness — ADR 0002)"]
settle["Settle<br/>fonts ready · images decoded · quiet ResizeObserver · delay<br/>(bounded by settleCap — ADR 0025)"]
collect["Collect<br/>roots · Stitch boundaries · size limits<br/>(refuse, never truncate — ADR 0022)"]
measure["Measure — the only DOM stage<br/>walk once; snapshot geometry, computed style, text ink;<br/>lift matching author CSS to plate-local rules (ADR 0005)"]
bundle[("per-Plate IR bundle<br/>one Light-DOM tree per View<br/>(Sweep resizes in one session — ADR 0010)")]
distil["Distil — annotate-only passes, fixed order (ADR 0022)<br/>walker → mark-ink → glyph-fold → Flatten →<br/>Superset → Crop → Template"]
serialize["Serialize — the sole projection<br/>realize annotations · Classify rules (ADR 0007) ·<br/>Gate each View by @media (ADR 0004)"]
post["POST /__xray<br/>{ plate, diagnostics? }"]
write["plugin validates (parseStoredPlate — ADR 0023)<br/>and writes the file"]
artifact[("plates/<name>.json<br/>the committed artifact")]
feedback["Blink on the captured element ·<br/>Hot-swap (tree via dev store, css via CSS-HMR)"]
ready --> settle --> collect --> measure --> bundle --> distil --> serialize --> post --> write
write --> artifact
write -.-> feedback
Capture is triggered by Readiness alone: the same condition that swaps the
skeleton for content also fires the capture, so what the user sees ready is
exactly what gets captured (ADR 0002). Settle then waits for the subtree to
stop moving, bounded by settleCap so a permanently busy subtree still
captures (ADR 0025).
The stages after Settle:
- Collect decides what enters the capture: which elements are roots, where
the walk stops (a nested
<Skeleton>becomes a Stitch reference, never descended into — ADR 0006), and the breadth/depth/node limits past which the capture is refused rather than trimmed. - Measure is the only stage that touches the browser. It walks the subtree once, snapshots each node’s geometry, text ink, and a frozen subset of its computed style onto the Light-DOM IR, and lifts the author CSS rules that matched each node, rewritten to plate-local identifiers with the cascade preserved by specificity and source order (ADR 0005, 0021, 0022). No later stage can read the DOM: the IR carries no live style object.
- Distil pares the IR down with pure annotate-only passes in a fixed order: measure faithfully, drop the imperceptible, compress what remains. The Walker applies author customization (ADR 0018); mark-ink classifies what each node paints; glyph-fold folds letter-sized inline marks into their text Run; Flatten collapses geometry-redundant wrapper chains; Superset drops Bones covered by a solid Bone; Crop drops Bones past the fold on both axes; Template collapses uniform sibling runs to one cell plus a count. Passes only set Annotations; none mutates the tree (ADR 0022).
- Serialize is the one place structure changes. It realizes every
Annotation, content-addresses the rules into per-plate classes (Classify,
ADR 0007), and attaches the
@mediaGate that confines each View to the width range it owns (ADR 0004). It runs in the browser over the whole bundle, so the analysis-only geometry never crosses the wire.
A Plate covers multiple Views. The Sweep (pressing record with the dev-only
extension connected — ADR 0010, ADR 0030) resizes the viewport through every
derived width band in one Session, appending one measured tree per View into
the same bundle — including empty Views for bands where a component is absent
or renders nothing — and ends the Session itself on success. Breakpoints are
derived from matching @media rules plus intercepted matchMedia calls, and
Serialize folds identical adjacent Views and prunes the boundaries between
them (ADR 0029); there is no breakpoints config.
The dev server’s POST /__xray endpoint treats every payload as untrusted: one
structural validator (parseStoredPlate) guards both the POST and disk reads,
size and depth caps bound the payload, and a css blob that could terminate a
<style> element is rejected at the door and escaped again at render
(ADR 0023). Diagnostics — records of anything the capture silently gave up —
ride a sibling field and are logged, never written to disk (ADR 0024).
Browse-time capture runs as a session: the HUD’s Capture toggle starts it, captures accumulate per Site and View while browsing and resizing, and stopping the toggle writes each buffered plate. A successful write triggers the Blink on the captured element and Hot-swaps mounted skeletons — the tree through the dev store, the css through CSS-HMR — with no page reload and no capture re-fire (ADR 0026).
Render: from the Plate on disk to the page
Section titled “Render: from the Plate on disk to the page”flowchart TD
artifact[("plates/<name>.json")]
vm["virtual:xray/plates/<name><br/>(Bake — ADR 0026)"]
tree["tree-only Plate, lowered to Chunk[]<br/>baked into the importing JS chunk (ADR 0008)"]
css["side-effect imports:<br/>virtual:xray/base.css · virtual:xray/plates/<name>.css<br/>→ Vite CSS pipeline → css asset, BASE deduped once"]
sk["<Skeleton plate={plate} loading={loading}>"]
bones["skeleton: inject Chunks — pre-serialized HTML runs;<br/>child plates mount at Stitches (ADR 0006)"]
content["content: children mount<br/>(and in dev, the next Capture fires)"]
gates["@media Gates show exactly one View per width<br/>no JS measurement · no FOUC · SSR-safe (ADR 0004)"]
reveal["Reveal: grace delay · minimum visible time · fade<br/>(ADR 0016; Continuations reveal instantly — ADR 0020)"]
artifact --> vm
vm --> tree --> sk
vm --> css
sk -->|"loading / suspended"| bones
sk -->|ready| content
bones --> gates
css --> gates
gates --> reveal
Importing virtual:xray/plates/<name> resolves to a module that Bakes the
committed file into the importer’s chunk: a tree-only default export plus two
side-effect css imports. The tree is lowered to Chunk[] at the ship boundary,
so an adapter injects opaque HTML strings instead of walking a tree, and no
per-framework serializer reaches the client (ADR 0008). The css — the plate’s
scoped rules plus the shared BASE stylesheet — travels through Vite’s own CSS
pipeline into ordinary css assets: cacheable independently of code, extracted
per chunk, BASE deduped once per page by module identity (ADR 0026). Adapters
inject no styles.
At runtime, <Skeleton plate> shows the skeleton while loading or suspended
and the children when ready; children never mount without data (ADR 0002). The
browser’s media engine picks the View for the current width through the carried
Gates — no JS width measurement, no flash of unstyled bones, and the same markup
on server and client, so the render is SSR-safe. Display timing is pure CSS: a
grace delay means fast loads never show a skeleton, a minimum visible time
prevents a blink once shown, and a Skeleton that replaces a still-visible
Stitch (a Continuation) reveals instantly instead of re-paying the delay
(ADR 0016, 0020).
Plates captured at runtime through @hueest/xray/core — outside the plugin —
carry their css explicitly instead: captureElement returns { plate, css },
and renderPlateHtml(plate, composeCss(css)) or
<Skeleton css={composeCss(css)}> renders them.
Core model
Section titled “Core model”- Plate vs Skeleton. A Plate is the stored structural capture; a Skeleton is the runtime, on-screen placeholder rendered from it. The two never share a word (the glossary enforces it).
- Plate passed by reference (ADR 0001).
A component imports its Plate from
virtual:xray/plates/<name>and hands it to<Skeleton>. The Plate carries its own identity: no name registry, nonamestring wired across call sites. - Readiness drives display and capture (ADR 0002).
loadinggates whether children mount; opt-insuspensecatches the mounted children while they resolve. There is no separate capture trigger. - A Plate is self-contained, as two artifacts (ADR 0003,
ADR 0026): a tree-only
Plateplus an independent scoped CSS string (including@media), under plate-local identifiers, depending on nothing in the consumer’s build. - One Plate, many Views, gated by
@media(ADR 0004). Each View is captured and kept whole, shown only across the width range it owns. Views are never merged into one tree: alignment of divergent DOMs corrupts. - Plate CSS is copied author rules (ADR 0005), not frozen computed styles: copied rules reflow correctly across viewports where frozen values do not.
- Every
<Skeleton>boundary is a Stitch (ADR 0006). A parent Plate references child Plates by name and never re-serializes them; the Stitch is the unit of capture, edit, and Hot-swap. - Content-addressed node classes (ADR 0007)
make rendering a node “an element with a
className,” which is what keeps adapters thin.
Integration surface
Section titled “Integration surface”An app touches xray in exactly two places: the plugin in vite.config.ts, and
one <Skeleton plate={plate}> per capture Site, with the plate imported from
virtual:xray/plates/<name>.
import { Skeleton } from '@hueest/xray/react' // real module — the componentimport plate from 'virtual:xray/plates/my-banner' // virtual module — the Plate;<Skeleton plate={plate} loading={loading}> <RealBanner data={data} /></Skeleton>Everything else follows from that pair: the plate name comes from the import
specifier, capture opt-in is passing plate, and per-Site tuning (settle
delay, capture Walker, Collect limits) rides <Skeleton> props. Deliberately
not configurable: breakpoints (derived), the virtual:xray/plates/ namespace,
capture naming, and per-component include/exclude lists.
Props, plugin options, and the core API live in the API Reference section, generated from source.
The Vite plugin
Section titled “The Vite plugin”Two plugins ship in one call (the vite-plugin-pwa pattern):
xray:data(always on) owns the virtual modules in dev and build:resolveId/loadread the committedplates/<name>.jsonand emit the Bake — tree-only default export plus thebase.cssand plate.cssimports.xray:dev(apply: 'serve') owns all capture machinery and is excluded from production builds: it injects the dev client viatransformIndexHtml, servesPOST /__xrayviaconfigureServer, and aliases@hueest/xray/react→@hueest/xray/react/devso the dev adapter (Light Box, Hot-swap, capture) exists only underserve. Builds fall through to the production adapter (ADR 0008).
Neither virtual module uses this.addWatchFile: a capture writes the plate
file, and watching it would loop. A plates-dir watcher covers hand edits and
git checkouts instead. On every plate write, both virtual modules are
invalidated, but only the .css module is HMR-reloaded — a live tree re-import
would remount the capture boundary and re-fire capture
(ADR 0026).
Dev tools
Section titled “Dev tools”- HUD (opt-in): the dev deck — record and Light Box controls, the status ticker, and the plates list with per-plate keys and Coverage segments (which width bands hold a captured View and which are missing). Solid-rendered in its own shadow DOM (ADR 0028).
- Light Box: flips every mounted
<Skeleton>to its skeleton for visual checks — globally or per Plate. Sticky per tab viasessionStorage; seedable with?xray-lightbox; scriptable via__XRAY__.mode.setLightbox(true). - Session: recording. While engaged, browsing, resizing, and the Sweep’s emulated widths accumulate captures through one path; the stop writes the buffered plates (ADR 0027). The Blink confirms each capture that changed a recorded result.
- Sweep: pressing record with the dev-only extension connected captures every View of every mounted Plate by driving the viewport through the derived width bands, then ends the Session itself (ADR 0010, ADR 0030).
- Fixtures: Record a Skeleton’s render-input
datato a sidecar and Replay it later, so hard-to-reproduce states can be re-captured on demand (ADR 0015).
Theming
Section titled “Theming”The skeleton’s appearance is driven entirely by inheritable CSS custom
properties, so consumers theme with the cascade: no props, no JS config
(ADR 0011). The tokens split
into two families (ADR 0017):
--xr-bone-* styles an individual Bone; --xr-skeleton-* governs
whole-skeleton display timing (ADR 0016).
Two architectural constraints shape the surface. Everything is progressive
enhancement: a plain bone fill plus opacity pulse is the universally supported
floor, and the only value-level features that can hard-break (light-dark(),
relative color) are @supports-guarded until Baseline Widely Available. And
the bones share one animation timeline under a display:contents root, which
is why a traveling shimmer and per-bone stagger are non-goals (ADR 0011); the
enter/exit swap is a separate opt-in concern (View Transitions,
ADR 0012).
The token table and recipes live in the theming guide.
Package layout
Section titled “Package layout”| Export / file | Runs in | Responsibility |
|---|---|---|
@hueest/xray (src/index.ts) |
node | the plugin(s): virtual modules, client injection, capture endpoint, HMR, the Bake |
@hueest/xray/react (react.tsx) |
browser | prod <Skeleton>: timing/reveal hooks + Suspense + static-refs stitches |
@hueest/xray/react/dev (react.dev.tsx) |
browser (dev) | dev <Skeleton>: Light Box, live-store Hot-swap, capture, store-subscribed stitches |
@hueest/xray/core (core.ts) |
browser | framework-neutral capture/render surface (ADR 0008): captureElement runs the full pipeline in one call, renderPlateHtml renders a stitch-free plate — the published layer beneath the plugin & adapters |
src/react.core.tsx |
browser | shared render core (hook-free renderPlate over chunks + useSkeletonTiming); the contract a new adapter mirrors |
src/chunk.ts |
node | framework-neutral serializer: build-time tree → shipped Chunk[] (ADR 0008) |
@hueest/xray/internal/client (client.ts) |
browser (dev) | internal entry point (not consumer API — the injected dev bootstrap imports it): settle + capture orchestration + matchMedia interception; runs Serialize and POSTs plates |
@hueest/xray/internal/hud (hud.ts) |
browser (dev) | internal entry point (not consumer API — the bootstrap wires it up when hud: true): the dev deck (record/Light Box, Coverage, per-plate keys), Solid-rendered — ADR 0028 |
src/measure.ts |
browser | Measure — the sole DOM-touching stage (ADR 0022 inv. 5): walks a live subtree directly into the Light-DOM IR (geometry/style/ink snapshotted); hosts distilPlate, which callers run before Serialize |
src/ir.ts |
browser | the Light-DOM IR (IRNode/PlateIR/LiteStyleDeclaration) + emit (IR → id-form {tree, rules}) and the effective* Annotation accessors (ADR 0021/0022) |
src/distil.ts |
browser | Distil — the annotate-only IR → IR passes in fixed order: markInk → glyph-fold → Flatten → Superset → Crop (2D) → Template (last) (ADR 0022 §3–4) |
src/text-metrics.ts |
browser | DOM text measurement (Measure layer): synthetic text bars + the @container line-count reflow rep via the pretext oracle (ADR 0019 §2) |
src/css-extract.ts |
browser | author-CSS lift (Measure layer): flatten/index/match author stylesheets, rewrite matched rules to plate-local ids, + the carried-custom-property decision (ADR 0005/0007) |
src/style-cache.ts |
browser | neutral leaf shared by the Measure modules: the per-capture getComputedStyle memo, the walk’s WalkState, and shared low-level CSS-value helpers |
src/serialize.ts |
browser | Serialize (ADR 0022 §5): the single in-browser projection serializePlateIR(PlateIR) → StoredPlate — realizes Annotations, Gates Views by breakpoint, content-addresses rules (Classify, ADR 0007) |
src/breakpoints.ts |
browser | View width-band derivation: deriveBreakpoints from matched @media + intercepted matchMedia; viewSpanFor maps a width to its band |
src/classify.ts |
browser | Classify (ADR 0007): content-keyed rule dedup, per-plate sequential class names, cascade-ordered emission |
src/validate.ts |
node | parseStoredPlate + the structural/security schema for the POST and disk reads (ADR 0023) |
src/diagnostics.ts |
browser + node | Diagnostic collection and the shared reduced-Fidelity report formatter (ADR 0024) |
src/blink.ts |
browser (dev) | the Blink: the one-shot capture confirmation over the captured element’s box |
