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..f4f0dc9 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"` 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. @@ -89,7 +90,21 @@ 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 + +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, 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. + +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 @@ -116,6 +131,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. | 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. 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. +

+
+
+ + + + 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/src/element/boundary.ts b/packages/bones/src/element/boundary.ts index f7e5e9c..aba6df3 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,11 @@ export class BonesBoundary extends Base { connectedCallback(): void { for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name); this.#connected = true; + // 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. @@ -109,6 +133,7 @@ export class BonesBoundary extends Base { } disconnectedCallback(): void { + this.#overlay.pause(); this.#connected = false; this.#clearTimer(); // A pending element that gets reparented (moved with appendChild, busy @@ -135,6 +160,10 @@ export class BonesBoundary extends Base { this.#defendOutput(); return; } + if (name === "precision") { + this.#syncPrecision(); + return; + } this.#evaluate(); } @@ -153,6 +182,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 +271,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 +287,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/measure.ts b/packages/bones/src/element/measure.ts new file mode 100644 index 0000000..45c4a21 --- /dev/null +++ b/packages/bones/src/element/measure.ts @@ -0,0 +1,126 @@ +// --------------------------------------------------------------------------- +// 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; +} + +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[] { + 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())) { + const converted = toRect(rect); + if (isVisible(converted)) textRects.push(converted); + } + 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/src/element/overlay.ts b/packages/bones/src/element/overlay.ts new file mode 100644 index 0000000..6fdcab4 --- /dev/null +++ b/packages/bones/src/element/overlay.ts @@ -0,0 +1,229 @@ +// --------------------------------------------------------------------------- +// 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; +} +/* 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"]: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; + } +} +`; + +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 { + // 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")); + applyStyles(root); + } + + activate(): void { + this.prepare(); + // 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")) { + this.#host.setAttribute("data-bones-auto", "off"); + this.#ownsAutoOff = true; + } + 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(); + } + + 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. 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); + } + + #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"); + // 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) / 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; + }), + ); + return true; + } +} 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( diff --git a/packages/bones/tests/boundary.test.ts b/packages/bones/tests/boundary.test.ts index 5cede20..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"; @@ -618,6 +620,94 @@ 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("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"); 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 0000000..0cf3796 Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/idle-content-chromium-linux.png differ 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 0000000..4a60af6 Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/measured-force-chromium-linux.png differ 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([]); +}); diff --git a/packages/bones/tests/browser/measured.test.ts b/packages/bones/tests/browser/measured.test.ts new file mode 100644 index 0000000..dd15eb7 --- /dev/null +++ b/packages/bones/tests/browser/measured.test.ts @@ -0,0 +1,184 @@ +/// +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 +// 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("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); + // 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); + } +}); + +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"); +}); 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"); +}); diff --git a/packages/bones/tests/measure.test.ts b/packages/bones/tests/measure.test.ts new file mode 100644 index 0000000..e529ce3 --- /dev/null +++ b/packages/bones/tests/measure.test.ts @@ -0,0 +1,74 @@ +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).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", () => { + 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(); + }); +});