Skip to content

check: line_unread counts every per-character span as a word #6443

Closed

Description

@jbernard077

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

  1. Set up the file above.
  2. 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

  1. added 2 commits that reference this issue on Oct 26, 2026
  2. added
    bugSomething isn't working
    triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions