How it works
Ghostwire’s synthesizer runs a five-phase pipeline against the live DOM, every time a host needs a fresh skeleton:
walk(host) ├─ 1. Collect → traverse the subtree, applying .ignore/.keep exclusions ├─ 2. Classify → assign a BoneType to every eligible node (text, heading, media, control, container, icon, panel) ├─ 3. Measure → one batched layout/style read pass — never interleaved with writes ├─ 4. Reduce → detect repeated siblings, fuse adjacent bones, cap node count └─ 5. Emit → a Bone Tree in coordinates relative to the hostPanels: A container whose own background, border, or shadow makes it visually distinct can be promoted to its own panel bone — walked like a normal container, but emitted as its own bone painted beneath its children, before they’re painted on top of it, rather than only being represented indirectly through them. The children are still emitted as their own separate bones, not replaced. This creates distinct visual regions in the skeleton without flattening the component tree. Enable via .panels on the directive or panels: true in #[Ghost]; off by default.
Read/write separation is load-bearing. Every layout read (getBoundingClientRect, getComputedStyle) happens before any DOM write — mixing them forces a synchronous reflow per element instead of one batched reflow for the whole host. This is the single biggest performance invariant in the codebase, and it’s enforced by a regression-guarding test, not just convention.
Repeat-sibling sampling measures the first three structurally-uniform siblings in a run for real, then clones bones for the rest — preserving real count and spacing without re-measuring hundreds of near-identical rows. Text bones are always measured for real, never cloned, because per-row text width genuinely varies.
Signature-based memoization hashes the classified candidate set (including repeat-run item count) so an unchanged host skips synthesis entirely on the next cycle, invalidated by a ResizeObserver watching border-box size.
Adaptive freeze degrades a host from synthesize to freeze mode automatically after two consecutive slow (>50ms) syntheses — a single slow sample is treated as GC/engine jitter, not a structural signal.
Slow hosts stay frozen. Once the adaptive freeze above trips, the host stays in freeze mode until it’s torn down — synthesis is never attempted again, so nothing ever re-measures it back to synthesize. This is intentional hysteresis, not a bug: re-testing a degraded host on every commit is exactly how flicker gets produced, since the cost that tripped the freeze is a property of the subtree, not of the one commit that happened to measure it. A page reload resets it, since the streak that tracks it lives only in memory for the lifetime of the page.
Try the algorithm directly, live, against your own markup, in the playground.