From 378de7b6ef10726bdae452966f31938c6bcd25ae Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:06:03 -0400 Subject: [PATCH 01/16] feat: add line-rect merging for measured bones (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/element/measure.ts | 78 +++++++++++++++++++++++++++ packages/bones/tests/measure.test.ts | 62 +++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/bones/src/element/measure.ts create mode 100644 packages/bones/tests/measure.test.ts diff --git a/packages/bones/src/element/measure.ts b/packages/bones/src/element/measure.ts new file mode 100644 index 0000000..be649be --- /dev/null +++ b/packages/bones/src/element/measure.ts @@ -0,0 +1,78 @@ +// --------------------------------------------------------------------------- +// Measurement for precision="measured" +// +// measureBones walks a boundary's light subtree and returns one rect per +// rendered text line and one per replaced element or form control, in +// viewport coordinates; the overlay converts them. Only a real layout engine +// produces geometry — in jsdom every rect is zero-area (or Range lacks +// getClientRects entirely) and the empty result is the CSS-fallback signal. +// --------------------------------------------------------------------------- + +export interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +export interface BoneRect extends Rect { + kind: "text" | "block"; +} + +// Mirrors the :is() list in auto.css's block override; the walk treats these +// as atomic boxes and never descends into them. +export const BLOCK_TAGS = new Set([ + "img", + "svg", + "video", + "canvas", + "picture", + "iframe", + "embed", + "object", + "audio", + "button", + "input", + "select", + "textarea", + "progress", + "meter", +]); + +// A text bar occupies this fraction of its measured line box, centered, +// approximating the 1ex bar bones.css centers on a 1lh line. +export const TEXT_BAR_SCALE = 0.55; + +function verticalOverlap(a: Rect, b: Rect): number { + return Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top); +} + +export function mergeLineRects(rects: Rect[]): Rect[] { + const sorted = [...rects].sort((a, b) => a.top - b.top || a.left - b.left); + const merged: Rect[] = []; + for (const rect of sorted) { + const line = merged.findLast((candidate) => { + if (verticalOverlap(candidate, rect) < Math.min(candidate.height, rect.height) / 2) { + return false; + } + const gap = rect.left - (candidate.left + candidate.width); + return gap <= Math.max(candidate.height, rect.height) / 2; + }); + if (line === undefined) { + merged.push({ ...rect }); + continue; + } + const right = Math.max(line.left + line.width, rect.left + rect.width); + const bottom = Math.max(line.top + line.height, rect.top + rect.height); + line.left = Math.min(line.left, rect.left); + line.top = Math.min(line.top, rect.top); + line.width = right - line.left; + line.height = bottom - line.top; + } + return merged; +} + +export function measureBones(root: Element): BoneRect[] { + void root; + return []; +} diff --git a/packages/bones/tests/measure.test.ts b/packages/bones/tests/measure.test.ts new file mode 100644 index 0000000..74c7061 --- /dev/null +++ b/packages/bones/tests/measure.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vite-plus/test"; +import { measureBones, mergeLineRects, type Rect } from "../src/element/measure.ts"; + +// --------------------------------------------------------------------------- +// mergeLineRects is pure geometry: rects from Range.getClientRects, one per +// line fragment, merged into one bar per visual line. Two rects share a line +// when they overlap vertically by at least half the shorter height; same-line +// neighbors merge when the horizontal gap is at most half the taller height. +// --------------------------------------------------------------------------- + +const rect = (left: number, top: number, width: number, height: number): Rect => ({ + left, + top, + width, + height, +}); + +describe("mergeLineRects", () => { + test("adjacent fragments on one line merge into one bar", () => { + // "Hello world" — a 4px space between fragments, 16px line. + const merged = mergeLineRects([rect(0, 0, 40, 16), rect(44, 0, 40, 16)]); + expect(merged).toEqual([rect(0, 0, 84, 16)]); + }); + + test("a gap wider than half the line height stays two bars", () => { + // Two columns 40px apart. + const merged = mergeLineRects([rect(0, 0, 100, 16), rect(140, 0, 100, 16)]); + expect(merged).toHaveLength(2); + }); + + test("separate lines never merge", () => { + const merged = mergeLineRects([rect(0, 0, 100, 16), rect(0, 24, 60, 16)]); + expect(merged).toEqual([rect(0, 0, 100, 16), rect(0, 24, 60, 16)]); + }); + + test("mixed heights on one line merge and take the union box", () => { + // Inline code or a larger inline span: same baseline area, taller box. + const merged = mergeLineRects([rect(0, 4, 40, 16), rect(42, 0, 30, 24)]); + expect(merged).toEqual([rect(0, 0, 72, 24)]); + }); + + test("input order does not matter", () => { + const merged = mergeLineRects([rect(44, 0, 40, 16), rect(0, 24, 60, 16), rect(0, 0, 40, 16)]); + expect(merged).toEqual([rect(0, 0, 84, 16), rect(0, 24, 60, 16)]); + }); + + test("empty input yields empty output", () => { + expect(mergeLineRects([])).toEqual([]); + }); +}); + +describe("measureBones in jsdom", () => { + test("returns no bones without a layout engine", () => { + // jsdom has no layout: rects are zero-area or getClientRects is missing. + // Empty result is the signal to stay on the CSS path. + const root = document.createElement("div"); + root.innerHTML = "

copy

"; + document.body.append(root); + expect(measureBones(root)).toEqual([]); + root.remove(); + }); +}); From cbc92e902dc950869c47b70bae9c64ffaa82b32e Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:11:59 -0400 Subject: [PATCH 02/16] test: pin the merge thresholds for measured bones (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/tests/measure.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/bones/tests/measure.test.ts b/packages/bones/tests/measure.test.ts index 74c7061..e529ce3 100644 --- a/packages/bones/tests/measure.test.ts +++ b/packages/bones/tests/measure.test.ts @@ -25,7 +25,19 @@ describe("mergeLineRects", () => { test("a gap wider than half the line height stays two bars", () => { // Two columns 40px apart. const merged = mergeLineRects([rect(0, 0, 100, 16), rect(140, 0, 100, 16)]); - expect(merged).toHaveLength(2); + expect(merged).toEqual([rect(0, 0, 100, 16), rect(140, 0, 100, 16)]); + }); + + test("a gap exactly half the taller height still merges", () => { + // gap 8 == 16 / 2 — the boundary is inclusive. + const merged = mergeLineRects([rect(0, 0, 40, 16), rect(48, 0, 40, 16)]); + expect(merged).toEqual([rect(0, 0, 88, 16)]); + }); + + test("vertical overlap exactly half the shorter height still merges", () => { + // overlap 8 == 16 / 2 — the boundary is inclusive. + const merged = mergeLineRects([rect(0, 0, 40, 16), rect(0, 8, 40, 16)]); + expect(merged).toEqual([rect(0, 0, 40, 24)]); }); test("separate lines never merge", () => { From 51b8d2ef86bd7bf64c0786d10b23a5113c559780 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:17:58 -0400 Subject: [PATCH 03/16] feat: measure text lines and block boxes for measured bones (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/element/measure.ts | 51 ++++++++++++- packages/bones/tests/browser/measure.test.ts | 76 ++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 packages/bones/tests/browser/measure.test.ts diff --git a/packages/bones/src/element/measure.ts b/packages/bones/src/element/measure.ts index be649be..a8ee1f7 100644 --- a/packages/bones/src/element/measure.ts +++ b/packages/bones/src/element/measure.ts @@ -72,7 +72,54 @@ export function mergeLineRects(rects: Rect[]): Rect[] { return merged; } +function isVisible(rect: Rect): boolean { + return rect.width > 0 && rect.height > 0; +} + +function toRect(rect: DOMRect): Rect { + return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; +} + export function measureBones(root: Element): BoneRect[] { - void root; - return []; + const doc = root.ownerDocument; + const blocks: BoneRect[] = []; + const textRects: Rect[] = []; + + const visit = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + if ((node as Text).data.trim() === "") return; + const range = doc.createRange(); + range.selectNodeContents(node); + // jsdom's Range has no getClientRects; no layout means no measured bones. + if (typeof range.getClientRects !== "function") return; + for (const rect of Array.from(range.getClientRects())) { + if (rect.width > 0 && rect.height > 0) textRects.push(toRect(rect)); + } + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const el = node as Element; + if (el.getAttribute("data-bones-auto") === "off") return; + if (BLOCK_TAGS.has(el.localName)) { + const rect = toRect(el.getBoundingClientRect()); + if (isVisible(rect)) blocks.push({ kind: "block", ...rect }); + return; + } + for (const child of node.childNodes) visit(child); + }; + + for (const child of root.childNodes) visit(child); + + const bones: BoneRect[] = blocks; + for (const line of mergeLineRects(textRects)) { + const height = line.height * TEXT_BAR_SCALE; + bones.push({ + kind: "text", + left: line.left, + top: line.top + (line.height - height) / 2, + width: line.width, + height, + }); + } + return bones; } diff --git a/packages/bones/tests/browser/measure.test.ts b/packages/bones/tests/browser/measure.test.ts new file mode 100644 index 0000000..4c8eed1 --- /dev/null +++ b/packages/bones/tests/browser/measure.test.ts @@ -0,0 +1,76 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import { measureBones, TEXT_BAR_SCALE } from "../../src/element/measure.ts"; + +// --------------------------------------------------------------------------- +// The DOM walk against a real layout engine. Fixtures use monospace with +// ch-based widths so line wrapping is deterministic on any platform. +// --------------------------------------------------------------------------- + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function mount(html: string): HTMLElement { + const root = document.createElement("div"); + root.style.cssText = + "width: 20ch; font: 16px/1.5 monospace; position: absolute; top: 0; left: 0;"; + root.innerHTML = html; + document.body.append(root); + return root; +} + +test("one text bone per rendered line", () => { + // 4-char words in a 20ch container: "aaaa bbbb cccc dddd" (19ch) fits line + // one, "eeee ffff" wraps to line two. + const root = mount('

aaaa bbbb cccc dddd eeee ffff

'); + const bones = measureBones(root); + expect(bones).toHaveLength(2); + expect(bones.every((bone) => bone.kind === "text")).toBe(true); + const [first, second] = bones; + expect(second.top).toBeGreaterThan(first.top); + expect(first.width).toBeGreaterThan(second.width); +}); + +test("text bars shrink to TEXT_BAR_SCALE of the line box, centered", () => { + const root = mount('

aaaa

'); + const text = root.querySelector("p")!.firstChild as Text; + const range = document.createRange(); + range.selectNodeContents(text); + const line = range.getClientRects()[0]; + const [bone] = measureBones(root); + expect(bone.height).toBeCloseTo(line.height * TEXT_BAR_SCALE, 0); + expect(bone.top + bone.height / 2).toBeCloseTo(line.top + line.height / 2, 0); + expect(bone.left).toBeCloseTo(line.left, 0); + expect(bone.width).toBeCloseTo(line.width, 0); +}); + +test("inline fragments on one line yield one bar", () => { + const root = mount('

aaaa bbbb cccc

'); + expect(measureBones(root)).toHaveLength(1); +}); + +test("replaced elements and form controls become block bones, atomically", () => { + const root = mount( + '', + ); + const bones = measureBones(root); + expect(bones.filter((bone) => bone.kind === "block")).toHaveLength(2); + // The button's text node must not also produce a text bone. + expect(bones.filter((bone) => bone.kind === "text")).toHaveLength(0); + const img = root.querySelector("img")!.getBoundingClientRect(); + const imgBone = bones.find((bone) => Math.abs(bone.height - 48) < 1)!; + expect(imgBone.left).toBeCloseTo(img.left, 0); + expect(imgBone.width).toBeCloseTo(img.width, 0); +}); + +test("data-bones-auto off subtrees are skipped", () => { + const root = mount( + '

aaaa

live region

', + ); + expect(measureBones(root)).toHaveLength(1); +}); + +test("whitespace-only and display:none content yields nothing", () => { + const root = mount('

hidden

'); + expect(measureBones(root)).toEqual([]); +}); From 562d9be06119a7ccc7b4bdd74cdfea67a2253111 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:25:24 -0400 Subject: [PATCH 04/16] feat: add precision=measured overlay to bones-boundary (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/element/boundary.ts | 45 +++++- packages/bones/src/element/overlay.ts | 202 +++++++++++++++++++++++++ packages/bones/tests/boundary.test.ts | 37 +++++ 3 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/bones/src/element/overlay.ts diff --git a/packages/bones/src/element/boundary.ts b/packages/bones/src/element/boundary.ts index f7e5e9c..e105b98 100644 --- a/packages/bones/src/element/boundary.ts +++ b/packages/bones/src/element/boundary.ts @@ -8,10 +8,19 @@ // an author controls; aria-busy and inert are outputs the element controls. // --------------------------------------------------------------------------- +import { MeasuredOverlay } from "./overlay.ts"; + export const DEFAULT_DELAY = 200; export const DEFAULT_MIN_DURATION = 400; -const UPGRADE_PROPERTIES = ["busy", "force", "delay", "minDuration", "transition"] as const; +const UPGRADE_PROPERTIES = [ + "busy", + "force", + "delay", + "minDuration", + "transition", + "precision", +] as const; type UpgradeProperty = (typeof UPGRADE_PROPERTIES)[number]; // "hiding" is the window a view transition leaves open: #hide has decided to @@ -38,7 +47,7 @@ const Base: typeof HTMLElement = export class BonesBoundary extends Base { // aria-busy and inert are outputs, observed only so the element can put // them back when something else strips them. See attributeChangedCallback. - static readonly observedAttributes = ["busy", "force", "aria-busy", "inert"]; + static readonly observedAttributes = ["busy", "force", "aria-busy", "inert", "precision"]; #state: State = "idle"; #timer: ReturnType | undefined; @@ -46,6 +55,7 @@ export class BonesBoundary extends Base { #shownAt = 0; #hideToken = 0; #writingOutput = false; + #overlay = new MeasuredOverlay(this as unknown as HTMLElement); get busy(): boolean { return this.hasAttribute("busy"); @@ -90,6 +100,15 @@ export class BonesBoundary extends Base { else this.removeAttribute("transition"); } + get precision(): "css" | "measured" { + return this.getAttribute("precision") === "measured" ? "measured" : "css"; + } + + set precision(value: "css" | "measured") { + if (value === "measured") this.setAttribute("precision", "measured"); + else this.removeAttribute("precision"); + } + get showing(): boolean { return this.#state === "showing" || this.#state === "draining" || this.#state === "hiding"; } @@ -97,6 +116,7 @@ export class BonesBoundary extends Base { connectedCallback(): void { for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name); this.#connected = true; + if (this.precision === "measured") this.#overlay.prepare(); // Server-rendered markup can carry aria-busy="true" so the CSS paints // bones before this script runs. Adopt that as "showing" rather than // re-running the delay; min-duration counts from now. @@ -106,9 +126,13 @@ export class BonesBoundary extends Base { return; } this.#evaluate(); + // A showing element that was moved re-measures at its new coordinates + // (#show already activated the overlay in the adopt branch above). + if (this.showing && this.precision === "measured") this.#overlay.activate(); } disconnectedCallback(): void { + this.#overlay.pause(); this.#connected = false; this.#clearTimer(); // A pending element that gets reparented (moved with appendChild, busy @@ -135,6 +159,10 @@ export class BonesBoundary extends Base { this.#defendOutput(); return; } + if (name === "precision") { + this.#syncPrecision(); + return; + } this.#evaluate(); } @@ -153,6 +181,17 @@ export class BonesBoundary extends Base { this.#writeOutput(true); } + // precision is not a timing input: flipping it never touches the state + // machine, only whether the current showing window draws measured bars. + #syncPrecision(): void { + if (this.precision === "measured") { + this.#overlay.prepare(); + if (this.showing) this.#overlay.activate(); + } else { + this.#overlay.deactivate(); + } + } + // A page that assigns boundary.busy = false before this module loads writes // an own data property onto the element. Upgrading does not remove it, and // it shadows the prototype accessor from then on, so the setter never runs @@ -231,6 +270,7 @@ export class BonesBoundary extends Base { this.#state = "showing"; this.#shownAt = Date.now(); this.#writeOutput(true); + if (this.precision === "measured") this.#overlay.activate(); this.dispatchEvent(new CustomEvent("bones:show", { bubbles: true, composed: true })); } @@ -246,6 +286,7 @@ export class BonesBoundary extends Base { if (this.#state !== "hiding" || token !== this.#hideToken) return; this.#state = "idle"; this.#writeOutput(false); + this.#overlay.deactivate(); this.dispatchEvent(new CustomEvent("bones:hide", { bubbles: true, composed: true })); }; if (this.#canTransition()) { diff --git a/packages/bones/src/element/overlay.ts b/packages/bones/src/element/overlay.ts new file mode 100644 index 0000000..1a949a9 --- /dev/null +++ b/packages/bones/src/element/overlay.ts @@ -0,0 +1,202 @@ +// --------------------------------------------------------------------------- +// MeasuredOverlay — draws measured bones into a shadow root +// +// The boundary owns *when* (its state machine); this class owns *what*: the +// shadow root, the bars, the two host marker attributes, and the +// ResizeObserver that re-measures. The shadow root gives the bars a home that +// page CSS, React reconciliation, and author selectors can never reach, while +// a single keeps the children in the light DOM where document +// stylesheets (auto.css included) still style them. +// --------------------------------------------------------------------------- + +import { measureBones } from "./measure.ts"; + +const OVERLAY_CSS = ` +:host([precision="measured"]) { + display: block; + position: relative; +} +/* Hidden content still lays out, so re-measurement stays valid. Deliberately + not !important: outer-tree rules beat ::slotted, which is what lets the + auto.css opt-out rule re-show exempt subtrees (and lets an author + visibility rule on a direct child win — a documented edge). */ +:host([data-bones-measured]) ::slotted(*) { + visibility: hidden; +} +[part~="overlay"] { + position: absolute; + inset: 0; + pointer-events: none; +} +[part~="bone"] { + position: absolute; + background: var(--bone-base, rgba(0, 0, 0, 0.12)); + border-radius: var(--bone-radius, 4px); +} +@keyframes bone-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +@keyframes bone-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +[part~="overlay"]:not([data-bone-animate]) [part~="bone"], +[part~="overlay"][data-bone-animate="shimmer"] [part~="bone"] { + animation: bone-shimmer var(--bone-duration, 1.5s) ease-in-out infinite; + background: linear-gradient( + 90deg, + var(--bone-base, rgba(0, 0, 0, 0.12)) 25%, + var(--bone-highlight, rgba(0, 0, 0, 0.06)) 50%, + var(--bone-base, rgba(0, 0, 0, 0.12)) 75% + ); + background-size: 200% 100%; +} +[part~="overlay"][data-bone-animate="pulse"] [part~="bone"] { + animation: bone-pulse var(--bone-duration, 1.5s) ease-in-out infinite; +} +[part~="overlay"][data-bone-animate="none"] [part~="bone"] { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + [part~="overlay"] [part~="bone"] { + animation: bone-pulse 2s ease-in-out infinite; + background: var(--bone-base, rgba(0, 0, 0, 0.12)); + background-size: auto; + } +} +`; + +let sharedSheet: CSSStyleSheet | undefined; + +function applyStyles(root: ShadowRoot): void { + if ( + "adoptedStyleSheets" in root && + typeof CSSStyleSheet !== "undefined" && + "replaceSync" in CSSStyleSheet.prototype + ) { + if (sharedSheet === undefined) { + sharedSheet = new CSSStyleSheet(); + sharedSheet.replaceSync(OVERLAY_CSS); + } + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sharedSheet]; + return; + } + const style = root.ownerDocument.createElement("style"); + style.textContent = OVERLAY_CSS; + root.append(style); +} + +export class MeasuredOverlay { + #host: HTMLElement; + #container: HTMLElement | undefined; + #observer: ResizeObserver | undefined; + #active = false; + // The author may set data-bones-auto themselves; only remove it on + // deactivate when this overlay put it there. + #ownsAutoOff = false; + + constructor(host: HTMLElement) { + this.#host = host; + } + + get active(): boolean { + return this.#active; + } + + // Attach the shadow root once, when precision becomes "measured" — at + // upgrade time in the common case, so the :host display change settles + // long before a show measures anything. + prepare(): void { + if (this.#host.shadowRoot) return; + const root = this.#host.attachShadow({ mode: "open" }); + root.append(this.#host.ownerDocument.createElement("slot")); + applyStyles(root); + } + + activate(): void { + this.prepare(); + if (!this.#renderBars()) { + // Nothing measurable (empty subtree, no layout engine): stay on the + // CSS path for this showing window. + this.deactivate(); + return; + } + if (!this.#active) { + this.#active = true; + if (!this.#host.hasAttribute("data-bones-auto")) { + this.#host.setAttribute("data-bones-auto", "off"); + this.#ownsAutoOff = true; + } + this.#host.setAttribute("data-bones-measured", ""); + } + this.#observe(); + } + + deactivate(): void { + this.#unobserve(); + this.#container?.remove(); + this.#container = undefined; + if (!this.#active) return; + this.#active = false; + this.#host.removeAttribute("data-bones-measured"); + if (this.#ownsAutoOff) { + this.#host.removeAttribute("data-bones-auto"); + this.#ownsAutoOff = false; + } + } + + // Disconnect stops the observer but keeps the bars: a showing element that + // moves keeps its skeleton, and the boundary re-activates on reconnect. + pause(): void { + this.#unobserve(); + } + + #observe(): void { + if (typeof ResizeObserver === "undefined" || this.#observer) return; + this.#observer = new ResizeObserver(() => { + // The callback runs after layout, so re-measuring here is sound. The + // bars are absolutely positioned in the shadow tree and never change + // the host's size, so this cannot loop. + if (this.#active) this.#renderBars(); + }); + this.#observer.observe(this.#host); + } + + #unobserve(): void { + this.#observer?.disconnect(); + this.#observer = undefined; + } + + #renderBars(): boolean { + const root = this.#host.shadowRoot; + if (!root) return false; + const bones = measureBones(this.#host); + if (bones.length === 0) return false; + const doc = this.#host.ownerDocument; + if (!this.#container) { + this.#container = doc.createElement("div"); + this.#container.setAttribute("part", "overlay"); + this.#container.setAttribute("aria-hidden", "true"); + root.append(this.#container); + } + // The overlay cannot see light-DOM ancestors from CSS, so the animation + // override attribute is mirrored onto the container at render time. + const animate = this.#host.closest("[data-bone-animate]")?.getAttribute("data-bone-animate"); + if (animate) this.#container.setAttribute("data-bone-animate", animate); + else this.#container.removeAttribute("data-bone-animate"); + const origin = this.#container.getBoundingClientRect(); + this.#container.replaceChildren( + ...bones.map((bone) => { + const bar = doc.createElement("div"); + bar.setAttribute("part", `bone bone-${bone.kind}`); + bar.style.left = `${bone.left - origin.left}px`; + bar.style.top = `${bone.top - origin.top}px`; + bar.style.width = `${bone.width}px`; + bar.style.height = `${bone.height}px`; + return bar; + }), + ); + return true; + } +} diff --git a/packages/bones/tests/boundary.test.ts b/packages/bones/tests/boundary.test.ts index 5cede20..b0248fa 100644 --- a/packages/bones/tests/boundary.test.ts +++ b/packages/bones/tests/boundary.test.ts @@ -618,6 +618,43 @@ describe("disconnect", () => { }); }); +describe("precision", () => { + test("reflects and parses like transition", () => { + const el = mount(); + expect(el.precision).toBe("css"); + el.precision = "measured"; + expect(el.getAttribute("precision")).toBe("measured"); + el.precision = "css"; + expect(el.hasAttribute("precision")).toBe(false); + el.setAttribute("precision", "wat"); + expect(el.precision).toBe("css"); + }); + + test("measured precision attaches a shadow root with a slot; css does not", () => { + expect(mount().shadowRoot).toBeNull(); + const el = mount({ precision: "measured" }); + expect(el.shadowRoot).not.toBeNull(); + expect(el.shadowRoot!.querySelector("slot")).not.toBeNull(); + }); + + test("in jsdom a measured show degrades to the CSS path", () => { + // No layout engine: measureBones returns nothing, so the overlay never + // activates, no marker attributes land, and aria-busy/inert still do. + const el = mount({ precision: "measured", force: "" }); + expect(shown(el)).toBe(true); + expect(el.hasAttribute("data-bones-measured")).toBe(false); + expect(el.hasAttribute("data-bones-auto")).toBe(false); + expect(el.shadowRoot!.querySelector('[part~="bone"]')).toBeNull(); + }); + + test("an author-set data-bones-auto is never removed", () => { + const el = mount({ precision: "measured", "data-bones-auto": "off", force: "" }); + el.force = false; + vi.runAllTimers(); + expect(el.getAttribute("data-bones-auto")).toBe("off"); + }); +}); + describe("auto.css contract", () => { test("a leaf inside a showing boundary matches the auto.css text-leaf selector", async () => { const { readFileSync } = await import("node:fs"); From 6be8959fbb580187d4b5e47c2732edd10568b4e8 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:32:36 -0400 Subject: [PATCH 05/16] fix: measure after the auto.css opt-out lands and sync precision on reconnect (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/element/boundary.ts | 9 +++++---- packages/bones/src/element/overlay.ts | 23 +++++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/bones/src/element/boundary.ts b/packages/bones/src/element/boundary.ts index e105b98..aba6df3 100644 --- a/packages/bones/src/element/boundary.ts +++ b/packages/bones/src/element/boundary.ts @@ -116,7 +116,11 @@ export class BonesBoundary extends Base { connectedCallback(): void { for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name); this.#connected = true; - if (this.precision === "measured") this.#overlay.prepare(); + // Runs before both exits below: it prepares the shadow root, re-measures + // a moved-while-showing element at its new coordinates, and rolls the + // overlay back if precision flipped to "css" while disconnected (whose + // own attributeChangedCallback never ran, since #connected was false). + this.#syncPrecision(); // Server-rendered markup can carry aria-busy="true" so the CSS paints // bones before this script runs. Adopt that as "showing" rather than // re-running the delay; min-duration counts from now. @@ -126,9 +130,6 @@ export class BonesBoundary extends Base { return; } this.#evaluate(); - // A showing element that was moved re-measures at its new coordinates - // (#show already activated the overlay in the adopt branch above). - if (this.showing && this.precision === "measured") this.#overlay.activate(); } disconnectedCallback(): void { diff --git a/packages/bones/src/element/overlay.ts b/packages/bones/src/element/overlay.ts index 1a949a9..188bacc 100644 --- a/packages/bones/src/element/overlay.ts +++ b/packages/bones/src/element/overlay.ts @@ -116,12 +116,11 @@ export class MeasuredOverlay { activate(): void { this.prepare(); - if (!this.#renderBars()) { - // Nothing measurable (empty subtree, no layout engine): stay on the - // CSS path for this showing window. - this.deactivate(); - return; - } + // The markers land before measuring: auto.css's leaf rules (min-width, + // min-height) key on their absence, and measuring while they are still + // active would inflate the geometry against a layout that snaps back a + // frame later. The ::slotted visibility rule this turns on does not + // affect layout, so measuring hidden content stays valid. if (!this.#active) { this.#active = true; if (!this.#host.hasAttribute("data-bones-auto")) { @@ -130,6 +129,12 @@ export class MeasuredOverlay { } this.#host.setAttribute("data-bones-measured", ""); } + if (!this.#renderBars()) { + // Nothing measurable (empty subtree, no layout engine): roll the + // markers back and stay on the CSS path for this showing window. + this.deactivate(); + return; + } this.#observe(); } @@ -157,8 +162,10 @@ export class MeasuredOverlay { this.#observer = new ResizeObserver(() => { // The callback runs after layout, so re-measuring here is sound. The // bars are absolutely positioned in the shadow tree and never change - // the host's size, so this cannot loop. - if (this.#active) this.#renderBars(); + // the host's size, so this cannot loop. Content that becomes + // unmeasurable while active (e.g. the subtree emptied out) falls back + // to the CSS path rather than leaving stale bars pinned in place. + if (this.#active && !this.#renderBars()) this.deactivate(); }); this.#observer.observe(this.#host); } From 77eb28b0430cee5233ffd4a0160413e0b3442783 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:42:35 -0400 Subject: [PATCH 06/16] feat: keep opted-out subtrees visible under measured bones (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/css/auto.css | 10 ++ packages/bones/tests/browser/measured.test.ts | 155 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 packages/bones/tests/browser/measured.test.ts diff --git a/packages/bones/src/css/auto.css b/packages/bones/src/css/auto.css index 37a6ca1..df314cb 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -1090,3 +1090,13 @@ } } } + +@layer bones-auto { + /* precision="measured" hides a boundary's content with inherited + visibility from the shadow side. Visibility, unlike display, can be + switched back on by a descendant, so the opt-out contract survives + measured mode: exempt subtrees stay visible under the overlay. */ + bones-boundary[data-bones-measured] [data-bones-auto="off"] { + visibility: visible; + } +} diff --git a/packages/bones/tests/browser/measured.test.ts b/packages/bones/tests/browser/measured.test.ts new file mode 100644 index 0000000..9aa3f4b --- /dev/null +++ b/packages/bones/tests/browser/measured.test.ts @@ -0,0 +1,155 @@ +/// +import { afterEach, expect, test } from "vite-plus/test"; +import "../../src/css/auto.css"; +import "../../src/element/index.ts"; +import type { BonesBoundary } from "../../src/element/index.ts"; + +// --------------------------------------------------------------------------- +// precision="measured" end to end in Chromium. Fixtures use monospace and +// ch widths so wrapping is deterministic; force skips the delay timer so +// activation is synchronous. +// --------------------------------------------------------------------------- + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function mount(inner: string, attrs = ""): BonesBoundary { + document.body.insertAdjacentHTML( + "beforeend", + `${inner}`, + ); + return document.querySelector("bones-boundary")!; +} + +function bars(el: BonesBoundary): HTMLElement[] { + return Array.from(el.shadowRoot!.querySelectorAll('[part~="bone"]')); +} + +const TWO_LINES = '

aaaa bbbb cccc dddd eeee ffff

'; + +test("shows one bar per line and marks the host", () => { + const el = mount(TWO_LINES); + expect(el.hasAttribute("data-bones-measured")).toBe(true); + expect(el.getAttribute("data-bones-auto")).toBe("off"); + expect(bars(el)).toHaveLength(2); +}); + +test("bars sit where the lines are", () => { + const el = mount(TWO_LINES); + const text = el.querySelector("p")!.firstChild!; + const range = document.createRange(); + range.selectNodeContents(text); + const lines = Array.from(range.getClientRects()); + const boxes = bars(el).map((bar) => bar.getBoundingClientRect()); + expect(boxes).toHaveLength(lines.length); + for (const [i, line] of lines.entries()) { + expect(boxes[i].left).toBeCloseTo(line.left, 0); + expect(boxes[i].width).toBeCloseTo(line.width, 0); + expect(boxes[i].top + boxes[i].height / 2).toBeCloseTo(line.top + line.height / 2, 0); + } +}); + +test("content hides while the overlay shows, and returns on hide", () => { + const el = mount(TWO_LINES); + const p = el.querySelector("p")!; + expect(getComputedStyle(p).visibility).toBe("hidden"); + el.force = false; + // min-duration=0, transition=none: the hide is synchronous. + expect(el.hasAttribute("data-bones-measured")).toBe(false); + expect(el.hasAttribute("data-bones-auto")).toBe(false); + expect(getComputedStyle(p).visibility).toBe("visible"); + expect(bars(el)).toHaveLength(0); +}); + +test("replaced elements get block bars", () => { + const el = mount(''); + const [bar] = bars(el); + expect(bar.getAttribute("part")).toBe("bone bone-block"); + const box = bar.getBoundingClientRect(); + const img = el.querySelector("img")!.getBoundingClientRect(); + expect(box.width).toBeCloseTo(img.width, 0); + expect(box.height).toBeCloseTo(img.height, 0); +}); + +test("an opted-out subtree gets no bars and stays visible", () => { + const el = mount(`${TWO_LINES}

live

`); + expect(bars(el)).toHaveLength(2); + // Scoped to the div specifically: while measuring, the host itself also + // carries data-bones-auto="off" (overlay-owned), so the unscoped + // `[data-bones-auto="off"] p` would match the TWO_LINES paragraph (a + // direct child of the host) before it reaches this subtree's own p. + const live = el.querySelector('div[data-bones-auto="off"] p')!; + // The auto.css rule flips inherited visibility back on for the subtree. + expect(getComputedStyle(live).visibility).toBe("visible"); +}); + +test("an empty boundary stays on the CSS path", () => { + const el = mount(""); + expect(el.hasAttribute("data-bones-measured")).toBe(false); + expect(el.hasAttribute("data-bones-auto")).toBe(false); + expect(bars(el)).toHaveLength(0); +}); + +test("resizing the host re-measures", async () => { + const el = mount(TWO_LINES); + expect(bars(el)).toHaveLength(2); + // 40ch fits all 29 characters on one line. + el.style.width = "40ch"; + await expect.poll(() => bars(el).length).toBe(1); +}); + +test("precision flips live while showing", () => { + const el = mount(TWO_LINES); + el.precision = "css"; + expect(el.hasAttribute("data-bones-measured")).toBe(false); + expect(bars(el)).toHaveLength(0); + el.precision = "measured"; + expect(el.hasAttribute("data-bones-measured")).toBe(true); + expect(bars(el)).toHaveLength(2); +}); + +// --------------------------------------------------------------------------- +// Coverage added after Task 7's review: the author-owned vs overlay-owned +// data-bones-auto split, and a precision flip that happens while the element +// is disconnected from the document. +// --------------------------------------------------------------------------- + +test("an author-set data-bones-auto on the host survives the overlay lifecycle", () => { + const el = mount(TWO_LINES, 'data-bones-auto="off"'); + expect(el.hasAttribute("data-bones-measured")).toBe(true); + expect(el.getAttribute("data-bones-auto")).toBe("off"); + // measureBones only skips descendant [data-bones-auto="off"] subtrees; the + // host itself is never checked, so its own content still gets bars. + expect(bars(el)).toHaveLength(2); + + el.force = false; + // min-duration=0, transition=none: the hide is synchronous. + expect(el.hasAttribute("data-bones-measured")).toBe(false); + // Author-owned: the overlay never put this attribute on, so it never + // takes it off. + expect(el.getAttribute("data-bones-auto")).toBe("off"); +}); + +test("the overlay-owned data-bones-auto is removed on hide", () => { + const el = mount(TWO_LINES); + expect(el.getAttribute("data-bones-auto")).toBe("off"); + el.force = false; + expect(el.hasAttribute("data-bones-auto")).toBe(false); +}); + +test("a precision flip while disconnected is honored on reconnect", () => { + const el = mount(TWO_LINES); + expect(bars(el)).toHaveLength(2); + el.remove(); + el.precision = "css"; + document.body.append(el); + expect(el.hasAttribute("data-bones-measured")).toBe(false); + expect(bars(el)).toHaveLength(0); + // data-bones-measured is gone, so the ::slotted hide rule no longer + // matches: content is visible either way, with or without the auto.css + // opt-out rule. + const p = el.querySelector("p")!; + expect(getComputedStyle(p).visibility).toBe("visible"); +}); From d06d4c73613e612a8f8a78fa2d9fb5a960778e1f Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:47:21 -0400 Subject: [PATCH 07/16] feat: expose precision on the BonesBoundary wrapper (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/src/react/boundary.ts | 1 + packages/bones/tests/boundary-react.test.tsx | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/bones/src/react/boundary.ts b/packages/bones/src/react/boundary.ts index 2d45d8b..a83f13d 100644 --- a/packages/bones/src/react/boundary.ts +++ b/packages/bones/src/react/boundary.ts @@ -25,6 +25,7 @@ interface ElementAttributes extends HTMLAttributes { delay?: number; "min-duration"?: number; transition?: "auto" | "none"; + precision?: "css" | "measured"; // React maps `className` to class on custom elements too, but a raw // written by hand reads better with the HTML name. class?: string; diff --git a/packages/bones/tests/boundary-react.test.tsx b/packages/bones/tests/boundary-react.test.tsx index 7a17bd6..9edc0e6 100644 --- a/packages/bones/tests/boundary-react.test.tsx +++ b/packages/bones/tests/boundary-react.test.tsx @@ -69,6 +69,15 @@ describe("server rendering", () => { expect(html.toLowerCase()).not.toContain("suppresshydrationwarning"); }); + test("precision passes through as an attribute", () => { + const html = renderToString( + +

copy

+
, + ); + expect(html).toContain('precision="measured"'); + }); + test("raw type-checks with the augmented intrinsic element", () => { const html = renderToString( From 666140e365e106fc933c6601e6e392da93d4a83c Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:50:37 -0400 Subject: [PATCH 08/16] test: screenshot the measured overlay (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/tests/browser/visual.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/bones/tests/browser/visual.test.tsx b/packages/bones/tests/browser/visual.test.tsx index d9bbb16..6fd876f 100644 --- a/packages/bones/tests/browser/visual.test.tsx +++ b/packages/bones/tests/browser/visual.test.tsx @@ -88,3 +88,19 @@ test("bare aria-busy region with auto.css", async () => { // @ts-expect-error — see the file-level comment above. await expect(page.getByTestId("bare-card")).toMatchScreenshot("bare-busy"); }); + +test("measured overlay over the card content", async () => { + mountHtml( + `${CONTENT}`, + ); + // @ts-expect-error — see the file-level comment above. + await expect(page.getByTestId("measured-card")).toMatchScreenshot("measured-force"); +}); + +test("the same card content, idle, for contrast", async () => { + mountHtml( + `${CONTENT}`, + ); + // @ts-expect-error — see the file-level comment above. + await expect(page.getByTestId("idle-card")).toMatchScreenshot("idle-content"); +}); From f5471d75e34b330085d92d4593cc9143f0dcfb90 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:52:44 -0400 Subject: [PATCH 09/16] ci: temporarily regenerate visual baselines with --update (BON-4) Reverted once the two measured linux references are harvested. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f6835..68a2048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,10 +73,10 @@ jobs: run: vp exec playwright install --with-deps chromium - name: Run browser tests - run: vp run --filter @camp.dev/bones test -- --run --project browser + run: vp run --filter @camp.dev/bones test -- --run --update --project browser - name: Upload screenshots - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: bones-visual-output From dd4f83a6460c28e974a342e9c3531bd8074ff3fd Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:55:35 -0400 Subject: [PATCH 10/16] feat: add a measured-bones sandbox page (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/sandbox/measured.html | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/bones/sandbox/measured.html diff --git a/packages/bones/sandbox/measured.html b/packages/bones/sandbox/measured.html new file mode 100644 index 0000000..1d6efac --- /dev/null +++ b/packages/bones/sandbox/measured.html @@ -0,0 +1,85 @@ + + + + + + measured bones sandbox + + + + + + +
+ + + + drag the dashed frame's corner to resize while bones show +
+ +
+ +

Measured bones

+

+ This paragraph wraps across several lines so each line gets its own bar, sized and + positioned from the rendered text itself rather than from a leaf-element guess. +

+

+ A shorter second paragraph, with bold and italic runs inline. +

+ avatar + +

+ This opted-out status line stays readable while everything else is bones. +

+
+
+ + + + From 631af61dd53039c7eca8070d4f63dd05a01617a3 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sun, 23 Aug 2026 23:56:43 -0400 Subject: [PATCH 11/16] test: commit measured linux screenshot baselines (BON-4) Harvested from the one-off --update CI run, which is reverted here. The three pre-existing linux baselines regenerated byte-identical. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 ++-- .../idle-content-chromium-linux.png | Bin 0 -> 7680 bytes .../measured-force-chromium-linux.png | Bin 0 -> 1365 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 packages/bones/tests/browser/__screenshots__/visual.test.tsx/idle-content-chromium-linux.png create mode 100644 packages/bones/tests/browser/__screenshots__/visual.test.tsx/measured-force-chromium-linux.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68a2048..44f6835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,10 +73,10 @@ jobs: run: vp exec playwright install --with-deps chromium - name: Run browser tests - run: vp run --filter @camp.dev/bones test -- --run --update --project browser + run: vp run --filter @camp.dev/bones test -- --run --project browser - name: Upload screenshots - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: bones-visual-output diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/idle-content-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/idle-content-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..0cf37964b28e1107e86f804f9f2d6612f8d5c29c GIT binary patch literal 7680 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 literal 0 HcmV?d00001 diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/measured-force-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/measured-force-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..4a60af6dfd144629d7528b866cf873f6f525c10f GIT binary patch literal 1365 zcmeAS@N?(olHy`uVBq!ia0y~yV3Y>34{@*o$;#}sA_fLl8BZ6-kP61P2OkDWWlOnT zyvsjd;nc*C%umOE@3&fqQsBMyVfP|j8V$GCnfymd$pXr{LA0-EMM&Eds}w8Y`5zrn^sPU*iMP# z6Q4g%x3jaGVB-JKqy2E>zV|a1fB#;tayHoF(!+v;b$sp2lb&gM1T`vnvNoxRa!d+Q zpdm+QEsN~Hz6+HJVQ{8FDSZeQo{`2Qd-Trh5wsVo!OLeAu=jG+?*tN^)l1XCI z`}_Mh-+n96)>v%br<|XkZ)0Qg;p4}~C7FjN0s{^xP~-Odubk)Q7G>VU2hy*8x1F?% z)BV-1zVoYITdDX8L*nwG$6C`}>mD2L+Ofj{Wa#SE+IF*5)?Ck4@x46b!Mk^TU#ooY zf3Fd{e*Li4qm{_0K~N zO#~^ceD!-?Fv!Pzo>pNNK|(5XmtT`wF4Q-r1r&3iH$~_~=uHQi4D{vMw8KdofhOy6 zzWzFEef<7QrLxN}%~-JY*xR?dlfLXGl=v1H9<=BKy6)R=+rtJ6_D6%G%4|08TGJ)L z89pDrd^vLZG`E%f`t|Go{P@`X`**ciH|wSU(K8aZ-(L9HxKDY1-CrlG)`BI85)#J+ z=Puv(exjeEOs0%`$-ZNSE+7e&7d5(K+)IKv+`XK#dFL*FTGUy>mDqIqc63zsi#>ln zpSK4_8OW}J%F2^}|Jv5xH|Amf^;>tk_pWu$R#T7dzYw)}Yn#eneH?igZ@eJ%BsUex z+;{lgE#lJwJ=}0rOJv1bYZAI zefo5dS=$W`-n}b3`)u3Mq=WOf#OVF}^pxA&+#D!zjAgB9UVc7FY4t!o17k8n?W^;1 RD}lu&gQu&X%Q~loCIIeLQ3e12 literal 0 HcmV?d00001 From 8631c94e094bd3924bda0ce87cbacb81fa195229 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 00:00:01 -0400 Subject: [PATCH 12/16] docs: document measured bones (BON-4) Co-Authored-By: Claude Fable 5 --- .changeset/measured-bones.md | 5 ++++ README.md | 14 +++++----- apps/docs/content/docs/api/bones-boundary.mdx | 28 ++++++++++++++----- packages/bones/README.md | 14 +++++----- 4 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 .changeset/measured-bones.md diff --git a/.changeset/measured-bones.md b/.changeset/measured-bones.md new file mode 100644 index 0000000..bc0aa39 --- /dev/null +++ b/.changeset/measured-bones.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": minor +--- + +Add `precision="measured"` to ``: the element measures its rendered content with `Range.getClientRects()` and draws pixel-accurate per-line overlay bones in a shadow root, re-measuring on resize and falling back to the `auto.css` path when there is nothing to measure. The React wrapper gains a matching `precision` prop, and `auto.css` keeps `[data-bones-auto="off"]` subtrees visible under the overlay. diff --git a/README.md b/README.md index 305480c..534e959 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,13 @@ import "@camp.dev/bones/css"; ## Entry points -| Import | Contents | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | -| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | -| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | -| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. | -| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | +| Import | Contents | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | +| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | +| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | +| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. `precision="measured"` draws pixel-accurate per-line bones measured from the content. | +| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it. diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index 6b05e46..64c7e22 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -39,13 +39,14 @@ The second script waits for the definition instead of trusting script order. A c ## Attributes -| Attribute | Property | Type | Default | Description | -| -------------- | ------------- | -------------------- | -------- | ------------------------------------------------------------------ | -| `busy` | `busy` | boolean | absent | The subtree is loading. | -| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. | -| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. | -| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. | -| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. | +| Attribute | Property | Type | Default | Description | +| -------------- | ------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `busy` | `busy` | boolean | absent | The subtree is loading. | +| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. | +| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. | +| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. | +| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. | +| `precision` | `precision` | `"css"` \| `"measured"` | `"css"` | How bones are drawn while showing. `"measured"` draws per-line overlay bones measured from the rendered content. | Every property reflects to its attribute. A `delay` or `min-duration` that is missing, negative, or not a number falls back to the default. The read-only `showing` property is `true` while bones are visible. @@ -91,6 +92,19 @@ Render the output attributes yourself when you know a region is loading at reque The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment. +## Measured bones + +With `precision="measured"`, the element measures its rendered content while bones show and draws the skeleton from the measurements: one bar per rendered line of text, one block per image or form control. Bars live in a shadow root, so page CSS and frameworks never see them; style them with `::part(bone)`, `::part(bone-text)`, `::part(bone-block)`, and `::part(overlay)`, or through the `--bone-*` custom properties, which inherit into the overlay. + +While the overlay shows, the element sets `data-bones-measured` and `data-bones-auto="off"` on itself, hides the content with inherited `visibility`, and re-measures whenever its size changes. When it hides, both attributes come off inside the same swap that removes `aria-busy`. If there is nothing to measure — an empty boundary, or an environment without layout — the element stays on the CSS path and `auto.css` draws leaf bones instead. + +A measured boundary is `display: block; position: relative` (the overlay needs a positioned block container). This applies from upgrade, not from the first show. + +Two edges to know about: + +- Content hiding uses a non-`!important` `::slotted` rule. An author rule that sets `visibility` on a direct child of the boundary wins over it and that child stays visible. The same cascade behavior is what lets `auto.css` keep `[data-bones-auto="off"]` subtrees visible under the overlay — without `auto.css` on the page, opted-out subtrees hide with everything else. +- Re-measurement is size-driven. Content that changes while busy without resizing the boundary (a fixed-height container whose children are swapped) keeps its previous bars until the next resize. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires no resize. + ## React `@camp.dev/bones/react` exports a typed wrapper. Import the element once in a client entry so the tag is registered. diff --git a/packages/bones/README.md b/packages/bones/README.md index 92be83c..49d2b10 100644 --- a/packages/bones/README.md +++ b/packages/bones/README.md @@ -32,13 +32,13 @@ import "@camp.dev/bones/css"; ## Entry points -| Import | Contents | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | -| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | -| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | -| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. | -| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | +| Import | Contents | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, ``, `` | +| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. | +| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. | +| `@camp.dev/bones/element` | ``, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. `precision="measured"` draws pixel-accurate per-line bones measured from the content. | +| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. | React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it. From 5b265a0bd5907366464c2cf5c5a1fc96ab479524 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 00:04:38 -0400 Subject: [PATCH 13/16] docs: list the precision prop in the React table (BON-4) Co-Authored-By: Claude Fable 5 --- apps/docs/content/docs/api/bones-boundary.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index 64c7e22..c6601bc 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -46,7 +46,7 @@ The second script waits for the definition instead of trusting script order. A c | `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. | | `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. | | `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. | -| `precision` | `precision` | `"css"` \| `"measured"` | `"css"` | How bones are drawn while showing. `"measured"` draws per-line overlay bones measured from the rendered content. | +| `precision` | `precision` | `"css"` or `"measured"` | `"css"` | How bones are drawn while showing. `"measured"` draws per-line overlay bones measured from the rendered content. | Every property reflects to its attribute. A `delay` or `min-duration` that is missing, negative, or not a number falls back to the default. The read-only `showing` property is `true` while bones are visible. @@ -130,6 +130,7 @@ function Profile({ loading, user }: { loading: boolean; user?: User }) { | `delay` | `delay` | | | `minDuration` | `min-duration` | | | `transition` | `transition` | | +| `precision` | `precision` | `"css"` or `"measured"`. Enables measured bones (see above). | | `onShow` | `bones:show` | Listener. Passing a function from a server component is an error, as with `onClick`. | | `onHide` | `bones:hide` | Same. | From d65a3dad33fe1353d1e889b73831e6a8d35394bc Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 00:16:06 -0400 Subject: [PATCH 14/16] fix: honor reduced motion in the measured overlay (BON-4) The shadow sheet's reduced-motion override lost to the shimmer selectors on specificity. Also documents the SSR upgrade seam, copies instead of aliasing the blocks array, and notes prepare()'s shadow-root assumption. Co-Authored-By: Claude Fable 5 --- apps/docs/content/docs/api/bones-boundary.mdx | 2 +- packages/bones/src/element/measure.ts | 5 +- packages/bones/src/element/overlay.ts | 11 +++- packages/bones/tests/boundary.test.ts | 53 +++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index c6601bc..e18c03e 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -90,7 +90,7 @@ Render the output attributes yourself when you know a region is loading at reque ``` -The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment. +The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment. With `precision="measured"`, the leaf bones auto.css painted before JavaScript ran are replaced by measured bars at upgrade time; the swap is the enhancement taking over. ## Measured bones diff --git a/packages/bones/src/element/measure.ts b/packages/bones/src/element/measure.ts index a8ee1f7..45c4a21 100644 --- a/packages/bones/src/element/measure.ts +++ b/packages/bones/src/element/measure.ts @@ -93,7 +93,8 @@ export function measureBones(root: Element): BoneRect[] { // jsdom's Range has no getClientRects; no layout means no measured bones. if (typeof range.getClientRects !== "function") return; for (const rect of Array.from(range.getClientRects())) { - if (rect.width > 0 && rect.height > 0) textRects.push(toRect(rect)); + const converted = toRect(rect); + if (isVisible(converted)) textRects.push(converted); } return; } @@ -110,7 +111,7 @@ export function measureBones(root: Element): BoneRect[] { for (const child of root.childNodes) visit(child); - const bones: BoneRect[] = blocks; + const bones: BoneRect[] = [...blocks]; for (const line of mergeLineRects(textRects)) { const height = line.height * TEXT_BAR_SCALE; bones.push({ diff --git a/packages/bones/src/element/overlay.ts b/packages/bones/src/element/overlay.ts index 188bacc..4274efe 100644 --- a/packages/bones/src/element/overlay.ts +++ b/packages/bones/src/element/overlay.ts @@ -58,8 +58,13 @@ const OVERLAY_CSS = ` [part~="overlay"][data-bone-animate="none"] [part~="bone"] { animation: none; } +/* Matches the shimmer/pulse selectors above at equal (0,3,0) specificity so + this override always wins the cascade instead of losing to source order. + data-bone-animate="none" is deliberately excluded: none still means none. */ @media (prefers-reduced-motion: reduce) { - [part~="overlay"] [part~="bone"] { + [part~="overlay"]:not([data-bone-animate]) [part~="bone"], + [part~="overlay"][data-bone-animate="shimmer"] [part~="bone"], + [part~="overlay"][data-bone-animate="pulse"] [part~="bone"] { animation: bone-pulse 2s ease-in-out infinite; background: var(--bone-base, rgba(0, 0, 0, 0.12)); background-size: auto; @@ -108,6 +113,10 @@ export class MeasuredOverlay { // upgrade time in the common case, so the :host display change settles // long before a show measures anything. prepare(): void { + // A boundary is not expected to carry an author shadow root, so any + // existing shadow root is assumed to be ours already. If an author did + // attach one, this leaves it alone rather than attaching a second — + // measured mode will not render bars into it correctly. if (this.#host.shadowRoot) return; const root = this.#host.attachShadow({ mode: "open" }); root.append(this.#host.ownerDocument.createElement("slot")); diff --git a/packages/bones/tests/boundary.test.ts b/packages/bones/tests/boundary.test.ts index b0248fa..8279b20 100644 --- a/packages/bones/tests/boundary.test.ts +++ b/packages/bones/tests/boundary.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; import { BonesBoundary } from "../src/element/index.ts"; @@ -655,6 +657,57 @@ describe("precision", () => { }); }); +describe("overlay reduced-motion contract", () => { + // The shimmer/pulse selectors and the reduced-motion override live in the + // same shadow sheet and must stay at equal specificity, or the override + // silently loses the cascade to source order. This pins the override's + // selector list against the selectors it needs to beat. + const overlaySource = readFileSync( + join(import.meta.dirname, "../src/element/overlay.ts"), + "utf8", + ); + + function extractReducedMotionBlock(source: string): string { + const marker = "@media (prefers-reduced-motion: reduce)"; + const start = source.indexOf(marker); + if (start === -1) throw new Error("reduced-motion media block not found in overlay.ts"); + const openBrace = source.indexOf("{", start); + let depth = 0; + let end = -1; + for (let i = openBrace; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) throw new Error("unbalanced braces in reduced-motion media block"); + return source + .slice(start, end + 1) + .replace(/\s+/g, " ") + .trim(); + } + + test("the override matches each shimmer/pulse selector at full specificity", () => { + const block = extractReducedMotionBlock(overlaySource); + for (const selector of [ + '[part~="overlay"]:not([data-bone-animate]) [part~="bone"]', + '[part~="overlay"][data-bone-animate="shimmer"] [part~="bone"]', + '[part~="overlay"][data-bone-animate="pulse"] [part~="bone"]', + ]) { + expect(block).toContain(selector); + } + }); + + test("the override deliberately excludes data-bone-animate=none", () => { + const block = extractReducedMotionBlock(overlaySource); + expect(block).not.toContain('[data-bone-animate="none"]'); + }); +}); + describe("auto.css contract", () => { test("a leaf inside a showing boundary matches the auto.css text-leaf selector", async () => { const { readFileSync } = await import("node:fs"); From f4639ca1e1fde1e64ae5736802b06bce4bb0907c Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 06:33:48 -0400 Subject: [PATCH 15/16] fix: compensate for scale transforms when placing measured bars (BON-4) Measured rects are post-transform, but bar CSS values re-enter the host's transformed space, so a scaled ancestor doubled every offset and size. Also corrects the docs' data-bones-auto lifecycle wording: an author-set value is never removed. Co-Authored-By: Claude Fable 5 --- apps/docs/content/docs/api/bones-boundary.mdx | 5 ++-- packages/bones/src/element/overlay.ts | 19 +++++++++++--- packages/bones/tests/browser/measured.test.ts | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/api/bones-boundary.mdx b/apps/docs/content/docs/api/bones-boundary.mdx index e18c03e..f4f0dc9 100644 --- a/apps/docs/content/docs/api/bones-boundary.mdx +++ b/apps/docs/content/docs/api/bones-boundary.mdx @@ -96,14 +96,15 @@ The stylesheet paints bones and the subtree is inert before the element upgrades With `precision="measured"`, the element measures its rendered content while bones show and draws the skeleton from the measurements: one bar per rendered line of text, one block per image or form control. Bars live in a shadow root, so page CSS and frameworks never see them; style them with `::part(bone)`, `::part(bone-text)`, `::part(bone-block)`, and `::part(overlay)`, or through the `--bone-*` custom properties, which inherit into the overlay. -While the overlay shows, the element sets `data-bones-measured` and `data-bones-auto="off"` on itself, hides the content with inherited `visibility`, and re-measures whenever its size changes. When it hides, both attributes come off inside the same swap that removes `aria-busy`. If there is nothing to measure — an empty boundary, or an environment without layout — the element stays on the CSS path and `auto.css` draws leaf bones instead. +While the overlay shows, the element sets `data-bones-measured` and, unless the author already set it, `data-bones-auto="off"` on itself. It hides the content with inherited `visibility` and re-measures whenever its size changes. When it hides, the attributes the overlay set come off inside the same swap that removes `aria-busy`; an author-set `data-bones-auto` is never touched. If there is nothing to measure — an empty boundary, or an environment without layout — the element stays on the CSS path and `auto.css` draws leaf bones instead. A measured boundary is `display: block; position: relative` (the overlay needs a positioned block container). This applies from upgrade, not from the first show. -Two edges to know about: +Three edges to know about: - Content hiding uses a non-`!important` `::slotted` rule. An author rule that sets `visibility` on a direct child of the boundary wins over it and that child stays visible. The same cascade behavior is what lets `auto.css` keep `[data-bones-auto="off"]` subtrees visible under the overlay — without `auto.css` on the page, opted-out subtrees hide with everything else. - Re-measurement is size-driven. Content that changes while busy without resizing the boundary (a fixed-height container whose children are swapped) keeps its previous bars until the next resize. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires no resize. +- Measurement compensates for scale transforms on the boundary or its ancestors, but not for rotation or skew. A rotated boundary draws its bars in the wrong place. ## React diff --git a/packages/bones/src/element/overlay.ts b/packages/bones/src/element/overlay.ts index 4274efe..6fdcab4 100644 --- a/packages/bones/src/element/overlay.ts +++ b/packages/bones/src/element/overlay.ts @@ -201,15 +201,26 @@ export class MeasuredOverlay { const animate = this.#host.closest("[data-bone-animate]")?.getAttribute("data-bone-animate"); if (animate) this.#container.setAttribute("data-bone-animate", animate); else this.#container.removeAttribute("data-bone-animate"); + // Measured rects are post-transform viewport geometry, but the bars' + // CSS values are laid out in the host's local space and then transformed + // again — under a scale(2) ancestor a naive subtraction doubles every + // offset and size. Divide by the container's scale factors (transformed + // bounding size over untransformed layout size) to land back in local + // space. Rotation and skew are not compensated. const origin = this.#container.getBoundingClientRect(); + const style = getComputedStyle(this.#container); + const layoutWidth = Number.parseFloat(style.width); + const layoutHeight = Number.parseFloat(style.height); + const scaleX = layoutWidth > 0 ? origin.width / layoutWidth : 1; + const scaleY = layoutHeight > 0 ? origin.height / layoutHeight : 1; this.#container.replaceChildren( ...bones.map((bone) => { const bar = doc.createElement("div"); bar.setAttribute("part", `bone bone-${bone.kind}`); - bar.style.left = `${bone.left - origin.left}px`; - bar.style.top = `${bone.top - origin.top}px`; - bar.style.width = `${bone.width}px`; - bar.style.height = `${bone.height}px`; + bar.style.left = `${(bone.left - origin.left) / scaleX}px`; + bar.style.top = `${(bone.top - origin.top) / scaleY}px`; + bar.style.width = `${bone.width / scaleX}px`; + bar.style.height = `${bone.height / scaleY}px`; return bar; }), ); diff --git a/packages/bones/tests/browser/measured.test.ts b/packages/bones/tests/browser/measured.test.ts index 9aa3f4b..d7d7069 100644 --- a/packages/bones/tests/browser/measured.test.ts +++ b/packages/bones/tests/browser/measured.test.ts @@ -51,6 +51,31 @@ test("bars sit where the lines are", () => { } }); +test("a scaled ancestor does not distort the bars", () => { + // Measured rects are post-transform; the bars' CSS values are laid out in + // the host's local space and transformed again. Without compensation, a + // scale(2) ancestor renders every bar at twice its size and offset. + document.body.insertAdjacentHTML( + "beforeend", + `
+ ${TWO_LINES} +
`, + ); + const el = document.querySelector("bones-boundary")!; + const text = el.querySelector("p")!.firstChild!; + const range = document.createRange(); + range.selectNodeContents(text); + const lines = Array.from(range.getClientRects()); + const boxes = bars(el).map((bar) => bar.getBoundingClientRect()); + expect(boxes).toHaveLength(lines.length); + for (const [i, line] of lines.entries()) { + expect(boxes[i].left).toBeCloseTo(line.left, 0); + expect(boxes[i].width).toBeCloseTo(line.width, 0); + expect(boxes[i].top + boxes[i].height / 2).toBeCloseTo(line.top + line.height / 2, 0); + } +}); + test("content hides while the overlay shows, and returns on hide", () => { const el = mount(TWO_LINES); const p = el.querySelector("p")!; From b8b84649996ddc687e02a029469703fe55315eaf Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 06:49:05 -0400 Subject: [PATCH 16/16] test: pin bar height under scale compensation (BON-4) Co-Authored-By: Claude Fable 5 --- packages/bones/tests/browser/measured.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bones/tests/browser/measured.test.ts b/packages/bones/tests/browser/measured.test.ts index d7d7069..dd15eb7 100644 --- a/packages/bones/tests/browser/measured.test.ts +++ b/packages/bones/tests/browser/measured.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, test } from "vite-plus/test"; import "../../src/css/auto.css"; import "../../src/element/index.ts"; import type { BonesBoundary } from "../../src/element/index.ts"; +import { TEXT_BAR_SCALE } from "../../src/element/measure.ts"; // --------------------------------------------------------------------------- // precision="measured" end to end in Chromium. Fixtures use monospace and @@ -73,6 +74,9 @@ test("a scaled ancestor does not distort the bars", () => { expect(boxes[i].left).toBeCloseTo(line.left, 0); expect(boxes[i].width).toBeCloseTo(line.width, 0); expect(boxes[i].top + boxes[i].height / 2).toBeCloseTo(line.top + line.height / 2, 0); + // The center alone would hide a vertical-scale regression: pin the + // rendered height to the shrunk line box in post-transform space too. + expect(boxes[i].height).toBeCloseTo(line.height * TEXT_BAR_SCALE, 0); } });