A snapshot records each line once, a line stops at the gutter, and a design document's directive governs what follows it

The re-review of the first round found four places where the new
measurements reached further than they should.

- The snapshot recorded the rects of every text node *under* each element,
  so a line rendered deep in a page was written down again for every
  ancestor above it. On a deep, text-heavy page that multiplies the
  capture by its depth and can carry it past the byte cap, and a capture
  that fails is a scan that returns nothing at all. Each element records
  only its own text now — one line, one entry — and `text_line_rects`
  assembles an element's lines by walking the tree the capture already
  serialized.

- Merging rects by vertical band alone made two columns that happen to
  sit on the same rows into one page-wide line. A fragment joins a row now
  only when it runs on from it: a horizontal gap no wider than the row's
  own line box. The fragments of a wrapped line are contiguous; a gutter
  is not. An inline image wider than the leading splits its line by the
  same test, which understates a line rather than overstating it, and that
  is the direction this rule should err in.

- In DESIGN.md, where the negative word sits decides what it governs. A
  state ("`.card-old` is deprecated") describes whatever its clause is
  about; a directive ("never use `.x`") condemns what follows it and
  nothing before it. Reading the whole clause for either lost the
  sanctioned half of "Use `.kicker` and never `.tagline`". The headings a
  document uses to retire a set — "Retired components", "Unsupported
  patterns" — are read as negative now, and a heading that names both
  sides ("Dos and Don'ts") heads a section of both, so its subsections are
  what say which is which.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
This commit is contained in:
Paul Bakaus
2026-09-20 19:13:13 -07:00
co-authored by Claude Fable 5.1
parent d1f81fc1c7
commit 0fea696a7f
6 changed files with 212 additions and 104 deletions
+22 -28
View File
@@ -69,29 +69,26 @@ const __SNAP_DEFAULT_MAX_BYTES = 48 * 1024 * 1024;
function __snapRect4(r) { return [r.x, r.y, r.width, r.height]; }
function __snapNum(v) { return typeof v === 'number' ? v : null; }
// The client rects of the non-blank text nodes under `node` (same walk as
// 10-probe.js#__collectTextRects). `deep` includes element descendants.
function __snapTextRects(node, deep, out) {
// The client rects of `node`'s own non-blank text nodes (same walk as
// 10-probe.js#__collectTextRects with `deep` off). Each element records only
// its own, so a line that rendered is recorded exactly once in a snapshot.
function __snapTextRects(node, out) {
for (const child of node.childNodes) {
if (child.nodeType === 3) {
if (!(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) out.push(rect);
}
range.detach?.();
} else if (deep && child.nodeType === 1) {
__snapTextRects(child, true, out);
if (child.nodeType !== 3) continue;
if (!(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) out.push(rect);
}
range.detach?.();
}
return out;
}
// getDirectTextRect(el): union of the client rects of the element's
// non-blank direct text nodes (same measure as 10-probe.js).
function __snapDirectTextRect(node) {
const rects = __snapTextRects(node, false, []);
function __snapDirectTextRectOf(rects) {
if (rects.length === 0) return null;
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -100,16 +97,6 @@ function __snapDirectTextRect(node) {
return [left, top, right - left, bottom - top];
}
// Every rect of the element's rendered text, descendants included: the
// snapshot half of `text_rects` in 10-probe.js. Recorded rather than derived,
// because a union of a long first line and a short tail says nothing about
// either, and a rule about lines that is handed only the union has to stand
// down. `textLines` on the snapshot is what tells the consumer these are
// here at all.
function __snapTextRects4(node) {
return __snapTextRects(node, true, []).map(r => [r.x, r.y, r.width, r.height]);
}
// ─── Linked stylesheet corpus (JS: injected/index.mjs #709) ────────────────
// JS: injected/index.mjs#pseudoElementHostSelector
@@ -650,10 +637,17 @@ const __impeccableSnapshot = {
rec.v = typeof el.checkVisibility === 'function'
? (el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }) ? 1 : 0)
: -1;
const dtr = __snapDirectTextRect(el);
// The element's OWN text rects, unmerged (`dl`), and their union
// (`d`, the long-standing field). Own and not the subtree's: every
// ancestor would otherwise carry a copy of every line under it, which
// on a deep text-heavy page multiplies the snapshot by its depth and
// can push it past the byte cap. The consumer walks the tree and
// assembles an element's lines from the rects its descendants each
// recorded once (`SnapshotDom::text_line_rects`).
const own = __snapTextRects(el, []);
const dtr = __snapDirectTextRectOf(own);
if (dtr) rec.d = dtr;
const tr = __snapTextRects4(el);
if (tr.length) rec.dl = tr;
if (own.length) rec.dl = own.map(r => [r.x, r.y, r.width, r.height]);
if (el.isContentEditable) rec.e = true;
if (el.hidden) rec.h = true;
if (typeof el.id !== 'string') rec.i = true;
+27
View File
@@ -968,6 +968,33 @@ mod tests {
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// Two columns that happen to sit on the same rows are two flows, not one
/// page-wide line. Fragments join a row only when they run on from it —
/// a gap no wider than the row's own line box — so a gutter keeps them
/// apart and the characters stay where the reader sees them.
#[test]
fn columns_on_the_same_rows_are_separate_lines() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(240), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
// Two 300px columns with a 100px gutter, three rows each.
d.set_text_lines(
p,
&[
(0.0, 100.0, 300.0, 19.0),
(400.0, 100.0, 300.0, 19.0),
(0.0, 124.0, 300.0, 19.0),
(400.0, 124.0, 300.0, 19.0),
(0.0, 148.0, 300.0, 19.0),
(400.0, 148.0, 300.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
// Six short lines of 40 characters each, not three of 700px.
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// A rect that is already one line is one line, whatever the leading is.
/// Dividing every rect by the line box turned a paragraph whose leading
/// is tighter than its glyph box into two copies of the same line, and
+60 -15
View File
@@ -44,10 +44,12 @@ re!(
"^\\.[A-Za-z_][A-Za-z0-9_-]*$".to_string()
);
// A design document's prose, cut where one statement stops and the next
// starts. `instead of` / `rather than` open a clause about what the document
// is steering *away* from; the reversals (`outside`, `except`, ...) open one
// about what it is steering *toward*, which is what lets "no ALL CAPS outside
// the `.eyebrow` class" declare `.eyebrow`.
// starts: punctuation, a line break, and the phrases that turn a sentence
// around. `instead of` / `rather than` open a
// clause about what the document is steering *away* from; the reversals
// (`outside`, `except`, ...) open one about what it is steering *toward*,
// which is what lets "no ALL CAPS outside the `.eyebrow` class" declare
// `.eyebrow`.
re!(
DESIGN_CLAUSE_SPLIT,
r"(?i)[.!?;:,()\[\]\n]|\u{2014}|\u{2013}|\binstead of\b|\brather than\b|\bas opposed to\b|\boutside\b|\bexcept\b|\bother than\b|\bunless\b|\bbesides\b|\bapart from\b|\bbeyond\b".to_string()
@@ -56,15 +58,30 @@ re!(
DESIGN_NEGATING_BOUNDARY,
r"(?i)^(?:instead of|rather than|as opposed to)$".to_string()
);
// Words that condemn whatever their clause names.
// A directive: it condemns what comes after it, and nothing before it.
// "Use `.kicker` and never `.tagline`" sanctions the first and forbids the
// second, and a rule that read the whole clause would lose both.
re!(
DESIGN_NEGATIVE_WORD,
r"(?i)\b(?:no|not|never|nor|none|avoid\w*|don'?t|do not|doesn'?t|drop|deprecat\w*|obsolete|legacy|forbidden|banned|disallow\w*|discourag\w*|retired|remove\w*|stop|wrong|unsupported|anti-?pattern\w*)\b".to_string()
DESIGN_DIRECTIVE_NEGATIVE,
r"(?i)\b(?:no|not|never|nor|none|avoid\w*|don'?t|do not|doesn'?t|does not|drop|remove\w*|stop|skip)\b".to_string()
);
// A state: it describes whatever its clause is about, wherever in the clause
// the name sits. "`.card-old` is deprecated" names the class first.
re!(
DESIGN_STATE_NEGATIVE,
r"(?i)\b(?:deprecat\w*|obsolete|legacy|forbidden|banned|disallow\w*|discourag\w*|retired|unsupported|wrong|bad|anti-?pattern\w*|no longer|not allowed|not permitted|not supported|not used)\b".to_string()
);
// Headings that introduce a section of counter-examples.
re!(
DESIGN_NEGATIVE_HEADING,
r"(?i)\b(?:don'?ts?|do not|avoid|never|not to|anti-?patterns?|deprecat\w*|forbidden|banned|legacy|obsolete|mistakes?|wrong|bad)\b".to_string()
r"(?i)\b(?:don'?ts?|do not|avoid|never|not to|anti-?patterns?|deprecat\w*|forbidden|banned|legacy|obsolete|retired|unsupported|removed|discourag\w*|disallow\w*|mistakes?|wrong|bad)\b".to_string()
);
// A heading that names both sides — "Do and Don't", "Dos and Don'ts",
// "Do / Do not" — introduces a section of both, so the subsections under it
// say which is which and the heading itself condemns nothing.
re!(
DESIGN_BOTH_SIDES_HEADING,
r"(?i)\bdos?\b[^\n]{0,12}?\b(?:do ?n[o']?ts?|do not)\b|\b(?:do ?n[o']?ts?|do not)\b[^\n]{0,12}?\bdos?\b".to_string()
);
re!(
FONT_SIZE_LITERAL_RE,
@@ -281,15 +298,23 @@ fn under_negative_heading(masked: &str, start: usize) -> bool {
chain.retain(|(l, _)| *l < level);
chain.push((level, text));
}
chain
.iter()
.any(|(_, text)| DESIGN_NEGATIVE_HEADING.is_match(text))
chain.iter().any(|(_, text)| {
// "Dos and Don'ts" heads a section of both, and the subsections
// under it are what say which is which.
DESIGN_NEGATIVE_HEADING.is_match(text) && !DESIGN_BOTH_SIDES_HEADING.is_match(text)
})
}
/// The clause the span sits in — from the boundary before it to the boundary
/// after it — and whether that clause condemns what it names. A clause opened
/// by "instead of" or "rather than" is condemned by the boundary itself,
/// whatever words follow.
/// after it — and whether that clause condemns what it names.
///
/// Where the negative word sits decides what it governs. A *state* ("`.x` is
/// deprecated") describes whatever the clause is about, so it condemns the
/// class wherever in the clause the name appears. A *directive* ("never use
/// `.x`") condemns what follows it and nothing before it, which is what keeps
/// "Use `.kicker` and never `.tagline`" from losing `.kicker`. A clause
/// opened by "instead of" or "rather than" is condemned by the boundary
/// itself, whatever words follow.
fn clause_condemns(masked: &str, start: usize, end: usize) -> bool {
let mut clause_start = 0usize;
let mut opened_by_negation = false;
@@ -304,7 +329,8 @@ fn clause_condemns(masked: &str, start: usize, end: usize) -> bool {
.find(&masked[end..])
.map(|m| end + m.start())
.unwrap_or(masked.len());
DESIGN_NEGATIVE_WORD.is_match(&masked[clause_start..clause_end])
DESIGN_STATE_NEGATIVE.is_match(&masked[clause_start..clause_end])
|| DESIGN_DIRECTIVE_NEGATIVE.is_match(&masked[clause_start..start])
}
pub fn parse_frontmatter(md: &str) -> Option<Map<String, Value>> {
@@ -2117,6 +2143,24 @@ mod tests {
let md = "- Buttons: `.btn-primary`, `.btn-old`.\n- `.btn-old` is deprecated.\n";
assert_eq!(declared_component_selectors(md), vec![".btn-primary"]);
// A directive governs what follows it, not the whole clause: a
// sentence that sanctions one class and forbids another says both.
let md = "- Use `.kicker` and never `.tagline`.\n\
- Every label is a `.kicker`, not a `.tagline`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// Headings that retire a set, in the words a document uses for it.
let md = "## Retired components\n\n- `.tagline`\n\n\
## Unsupported patterns\n\n- `.marquee-row`\n\n\
## Components\n\n- `.kicker`\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// A heading that names both sides heads a section of both, and its
// subsections are what say which is which.
let md = "## Dos and Don'ts\n\n### Do\n\n- Label a section with `.kicker`.\n\n\
### Don't\n\n- Reach for `.eyebrow`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// What the negative words govern is their own clause. "No ALL CAPS
// outside the `.eyebrow` class" declares `.eyebrow`, and "four kinds
// and no more" declares all four.
@@ -2463,3 +2507,4 @@ mod tests {
}
}
+45 -16
View File
@@ -193,13 +193,24 @@ pub trait Dom {
}
/// The rects of one element's rendered text, merged into the lines they
/// rendered on: rects whose vertical band overlaps the band the row started
/// with belong to that row, and a row is the union of its fragments.
/// rendered on.
///
/// Sorting is by top then left, so a row's fragments arrive together, and
/// membership is tested against the *first* rect of the row rather than the
/// row as it grows — an inline-block taller than the leading would otherwise
/// swallow the line beneath it.
/// Two rects are the same line when they share a row *and* run on from each
/// other. Sharing a row is a vertical band overlapping the band the row
/// started with by more than half the shorter height — that is what makes an
/// inline `<strong>`, a superscript and the text around them one line.
/// Running on is a horizontal gap no wider than the row's own line box: the
/// fragments of a wrapped line are contiguous, while two columns of text that
/// happen to sit on the same rows are separated by a gutter, and unioning
/// those would invent a page-wide line neither column ever rendered. An
/// inline image wider than the leading splits its line in two by the same
/// test, which understates a line rather than overstating it — the direction
/// this rule should err in.
///
/// Rects arrive in whatever order a DOM walked the text (an element's own
/// text and its descendants' are interleaved on the page but not in the
/// walk), so they are sorted top then left first and each rect joins the
/// newest row still level with it.
pub fn merge_text_rects_into_lines(rects: Vec<Rect>) -> Vec<Rect> {
let mut rects: Vec<Rect> = rects
.into_iter()
@@ -212,23 +223,41 @@ pub fn merge_text_rects_into_lines(rects: Vec<Rect>) -> Vec<Rect> {
.then(a.left.partial_cmp(&b.left).unwrap_or(std::cmp::Ordering::Equal))
});
let mut lines: Vec<Rect> = Vec::new();
// The band of the rect each row started with.
// The band of the rect each row started with. Membership is tested
// against that rather than against the row as it grows, so an
// inline-block taller than the leading does not swallow the line beneath.
let mut bands: Vec<(f64, f64)> = Vec::new();
for r in rects {
if let (Some(line), Some(&(band_top, band_bottom))) = (lines.last_mut(), bands.last()) {
let mut joined = false;
for i in (0..lines.len()).rev() {
let (band_top, band_bottom) = bands[i];
// Sorted by top: once a row sits entirely above this rect, every
// row before it does too.
if band_bottom <= r.top {
break;
}
let overlap = band_bottom.min(r.bottom) - band_top.max(r.top);
let shorter = (band_bottom - band_top).min(r.height);
if shorter > 0.0 && overlap > shorter / 2.0 {
let left = line.left.min(r.left);
let top = line.top.min(r.top);
let right = line.right.max(r.right);
let bottom = line.bottom.max(r.bottom);
*line = Rect::from_xywh(left, top, right - left, bottom - top);
if shorter <= 0.0 || overlap <= shorter / 2.0 {
continue;
}
let line = lines[i];
let gap = (r.left - line.right).max(line.left - r.right);
if gap > band_bottom - band_top {
continue;
}
let left = line.left.min(r.left);
let top = line.top.min(r.top);
let right = line.right.max(r.right);
let bottom = line.bottom.max(r.bottom);
lines[i] = Rect::from_xywh(left, top, right - left, bottom - top);
joined = true;
break;
}
if !joined {
bands.push((r.top, r.bottom));
lines.push(r);
}
bands.push((r.top, r.bottom));
lines.push(r);
}
lines
}
+34 -15
View File
@@ -273,12 +273,14 @@ pub struct SnapNode {
/// `getDirectTextRect` as `[x, y, width, height]`.
#[serde(rename = "d", default)]
pub direct_text_rect: Option<[f64; 4]>,
/// The client rects of the element's rendered text, descendants included,
/// each `[x, y, width, height]`. What a live probe reads on demand, read
/// once at capture; the fragments are merged into lines on this side, so
/// what travels is the raw list. Empty is "no rendered text", not "not
/// recorded" — `Snapshot::text_lines` is what says a capture recorded
/// them at all.
/// The client rects of the element's OWN direct text, unmerged, each
/// `[x, y, width, height]` — `direct_text_rect` before it was merged into
/// a union. Own and not the subtree's: an element's rendered lines are
/// assembled from these across its descendants
/// ([`SnapshotDom::text_line_rects`]), so a line that rendered is
/// recorded once rather than once per ancestor. Empty is "no rendered
/// text", not "not recorded" — [`Snapshot::text_lines`] is what says a
/// capture recorded them at all.
#[serde(rename = "dl", default, skip_serializing_if = "Vec::is_empty")]
pub text_rects: Vec<[f64; 4]>,
#[serde(rename = "e", default)]
@@ -925,6 +927,10 @@ impl Dom for SnapshotDom {
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.snap.node(el).direct_text_rect.as_ref().map(rect4)
}
/// Assembled from the per-element rects the capture recorded: the
/// element's own, then each descendant's, in document order, merged into
/// the lines they rendered as.
///
/// `None` on a capture that never recorded them. The union in `d` is not
/// an answer here: a paragraph with one long line and a short tail has
/// the same union as one with two even lines, so dividing it by a line
@@ -933,9 +939,16 @@ impl Dom for SnapshotDom {
if !self.snap.text_lines {
return None;
}
Some(super::dom::merge_text_rects_into_lines(
self.snap.node(el).text_rects.iter().map(rect4).collect(),
))
fn walk(snap: &Snapshot, el: ElId, out: &mut Vec<Rect>) {
let node = snap.node(el);
out.extend(node.text_rects.iter().map(rect4));
for child in &node.children {
walk(snap, *child, out);
}
}
let mut rects = Vec::new();
walk(&self.snap, el, &mut rects);
Some(super::dom::merge_text_rects_into_lines(rects))
}
}
@@ -1128,23 +1141,29 @@ mod tests {
}"#;
assert_eq!(snap(OLD).text_line_rects(3), None);
// Each element records only its own text rects; an element's lines
// are assembled from its own plus its descendants'. Here the `<p>`
// wraps once and an inline `<b>` sits in the middle of its first
// line, so the first line arrives in three pieces from two elements.
const NEW: &str = r#"{
"v": 1, "textLines": true, "documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2]},
{"t":"BODY","p":1,"c":[3]},
{"t":"P","p":2,"c":["hello"],"d":[0,100,1000,43],
"dl":[[0,100,600,19],[600,100,400,19],[0,124,120,19]]}
{"t":"P","p":2,"c":["hello ",4," there"],"d":[0,100,1000,43],
"dl":[[0,100,600,19],[700,100,300,19],[0,124,120,19]]},
{"t":"B","p":3,"c":["bold"],"d":[600,100,100,19],"dl":[[600,100,100,19]]}
]
}"#;
let lines = snap(NEW).text_line_rects(3).expect("lines");
// The two fragments of the first line are the one line they rendered
// as; the tail is its own.
assert_eq!(lines.len(), 2);
assert_eq!((lines[0].left, lines[0].width), (0.0, 1000.0));
assert_eq!((lines[1].top, lines[1].width), (124.0, 120.0));
// An element the capture found no rendered text on is not "unknown".
assert_eq!(snap(NEW).text_line_rects(2), Some(Vec::new()));
// The `<b>` on its own is the one line it rendered.
assert_eq!(snap(NEW).text_line_rects(4).expect("lines").len(), 1);
// An element the capture found no rendered text under is not
// "unknown" — it is an element with no lines.
assert_eq!(snap(NEW).text_line_rects(1).map(|l| l.len()), Some(2));
}
#[test]
File diff suppressed because one or more lines are too long