Repository navigation
Description
Describe the bug
The Craft audit's line_unread rule counts words from a block's text, but it builds that text by joining the block's parts with a space. The probe emits one part per element that has its own text, so a line built from one <span> per glyph (a typewriter, a per-letter entrance, a kerned headline) arrives as one part per character and is joined into T h a t c a m e r a …. readingWords then counts every letter as a word: a seven-word line is reported as 25 words, its hold target becomes 8.0 s instead of 2.6 s, and a 5.5 s hold is flagged as under a 6.0 s floor. The same seven words as plain text pass.
The join is block.text = \${block.text} ${part.text}`inpackages/cli/src/utils/craftAudit.ts (readableBlocks); the parts come from craftTextPartsinpackages/cli/src/commands/layout-audit.browser.js, which reads each element's own text nodes only (textContentFor(element, true)`), so the author's spaces between words are inside the parts and nothing in the join knows whether two parts were separated by whitespace.
Minimal reproduction
One file, index.html, in a blank project (npx hyperframes init repro --non-interactive --example blank, then replace index.html):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #202020; }
#root { width: 100%; height: 100%; font-family: sans-serif; }
.line { position: absolute; left: 160px; top: 480px; color: #ffffff; font-size: 64px; font-weight: 600; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="12" data-width="1920" data-height="1080">
<!-- A: seven words, one span per character -->
<p id="spans" class="line clip" data-start="0" data-duration="5.5" data-track-index="0"></p>
<!-- B: the same seven words as plain text -->
<p id="plain" class="line clip" data-start="6" data-duration="5.5" data-track-index="1">That camera move at 0:12 though</p>
</div>
<script>
var text = "That camera move at 0:12 though";
var host = document.getElementById("spans");
for (var i = 0; i < text.length; i++) {
var s = document.createElement("span");
s.textContent = text[i] === " " ? " " : text[i];
host.appendChild(s);
}
var tl = gsap.timeline({ paused: true });
tl.to({}, { duration: 12 });
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
tl.seek(0);
</script>
</body>
</html>Steps to reproduce
- Set up the file above.
- Run
npx hyperframes@0.8.204 check.
Expected
Both lines are the same seven words held for 5.5 s. line_unread's target for seven words is 0.5 s + 0.3 s × 7 = 2.6 s, so neither line should be flagged.
Actual
Craft
⚠ line_unread: "T h a t c a m e r a m o v e a t 0 : 1 2 t h o u g h" (25 words) is on screen 5.50 s (±0.01 s), under its 6.00 s floor: hold it about 0.5 s + 0.3 s a word (8.00 s here) before it leaves or changes; see references/motion.md.
index.html #spans t=0s
Line B (#plain) is not flagged. (The dead_stretch warning the file also produces is expected and unrelated.)
Suggested fix
Count the words from the block's own rendered text rather than from a space-joined list of its parts: for example have craftTextParts also report textContentFor(block, false) for each block (its innerText, which keeps the author's spacing and collapses whitespace), and let readableBlocks use that for text while still unioning the parts' rects. Joining the parts with "" would also fix this case but would glue genuinely separate inline parts together (<b>One</b> <i>two</i> → "Onetwo").
Environment
- hyperframes 0.8.204 (the rule has behaved this way since it shipped in 0.8.185); macOS, Chrome via the CLI's bundled browser.
Activity
chore(upstream): adopt ad4d3fa (merge heygen-com/hyperframes main)
docs(upstream): record the 8f618c5 batch, advance commit watermark an…