A rendered line is the whole line: the fragments merge, the snapshot carries them or the rule stands down, an ignored subtree gets no vote in the palette, and a class a design document forbids declares nothing

The review threads on #840 found four ways the new rendered rules were
still measuring something other than what the reader sees.

- `line-length` read one `getClientRects()` box per direct text node and
  called each of them a rendered line. An inline `<strong>` in the middle
  of a sentence is its own text node, so one visual line arrived as two
  fragments and the paragraph's characters were divided between them —
  and the `<strong>`'s own text was never measured at all while its
  characters still counted toward `text_len`. Both halves of the measure
  are the same text now: the probe collects the rects of every text node
  under the element, and `Dom::text_line_rects` merges the ones that share
  a row back into the line they rendered as. A column of long lines split
  by inline markup used to charge nothing; it charges now, and a wrapped
  sentence in two fragments is one line, not two.

- The same function divided every rect by the line box to recover a line
  count. On live per-line rects that was double-counting: a leading
  tighter than the glyph box makes `round(height / line_box)` 2, and one
  long line pushed twice satisfied "at least two long lines". Nothing is
  divided any more, because nothing that reaches the rule is a union.

- The union was what a snapshot-backed scan had — the extension's
  offscreen document and any strict-CSP page — and a union of a long
  first line and a short tail is the same union as two even lines, so
  every line inferred from it was invented. The capture records the rects
  now (`dl`, with `textLines` on the snapshot saying it did), and a
  capture that did not answers `None`: the rule stands down rather than
  guessing. That is also what any other DOM that cannot split a wrapped
  run answers.

- `ai-color-palette` accumulated its tell hues before the scoped ignores
  ran. A cyan tell inside a `data-impeccable-ignore="ai-color-palette"`
  subtree opened the page-wide two-hue gate and charged neon ink on an
  element nobody had waived. Ignored content now gets no vote.

- `declared_component_selectors` took every backticked class in DESIGN.md
  as a declaration, including the ones the document writes down in order
  to forbid them. "Do not write a new `.hero-cta-primary`" exempted
  `.hero-cta-primary` from `kicker-above-heading` — the parser silencing
  exactly the misuse the document was written to catch. Each occurrence
  is read in the document's own structure now: the heading chain above it
  (a "Don't" section, and its subsections with it) and the clause it sits
  in, where a clause is cut on punctuation and on the phrases that turn a
  sentence around. "No ALL CAPS outside the `.eyebrow` class" still
  declares `.eyebrow`, because what "no" governs ends at "outside"; a
  class the document calls deprecated anywhere is declared nowhere.

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:02:46 -07:00
co-authored by Claude Fable 5.1
parent a5df2b0826
commit d1f81fc1c7
15 changed files with 630 additions and 147 deletions
+30 -17
View File
@@ -46,22 +46,33 @@ function __rectArray(r) {
return [r.x, r.y, r.width, r.height, r.top, r.right, r.bottom, r.left];
}
// The client rects of an element's non-blank direct text nodes, one per line
// box, in document order. Both text-rect probes read the page through this:
// the union one merges them, the line one hands them over as they are.
function __textLineRects(el) {
const node = __el(el);
const rects = [];
// The client rects of the non-blank text nodes under `node`, in document
// order. `deep` walks element children too: one line of prose is one line
// box however the markup splits it, and an inline <strong>, an <a> or a
// framework marker in the middle of a sentence is a separate text node whose
// rects belong to the same line. Nothing is merged here — the rects travel as
// the page gave them and the consumer groups them into lines (see
// merge_text_rects_into_lines in crates/foundation/src/browser/dom.rs).
function __collectTextRects(node, deep, out) {
for (const child of node.childNodes) {
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
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) {
__collectTextRects(child, true, out);
}
range.detach?.();
}
return rects;
return out;
}
// The element's own direct text, unmerged.
function __textLineRects(el) {
return __collectTextRects(__el(el), false, []);
}
const __impeccableDom = {
@@ -207,11 +218,13 @@ const __impeccableDom = {
const bottom = Math.max(...rects.map(r => r.bottom));
return [left, top, right - left, bottom - top, top, right, bottom, left];
},
// The same rects, unmerged: getClientRects() returns one per line box, so
// this is the element's text line by line, flattened into eights.
direct_text_line_rects(el) {
// Every rect of the element's rendered text, descendants included, flattened
// into eights. The scope is the element's whole text_content, which is the
// text a caller counts characters from; the caller merges the rects that
// share a row into the line they rendered on.
text_rects(el) {
const out = [];
for (const r of __textLineRects(el)) {
for (const r of __collectTextRects(__el(el), true, [])) {
out.push(r.left, r.top, r.width, r.height, r.top, r.right, r.bottom, r.left);
}
return out;
+33 -10
View File
@@ -69,19 +69,29 @@ 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) {
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);
}
}
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 = [];
for (const child of node.childNodes) {
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
}
range.detach?.();
}
const rects = __snapTextRects(node, false, []);
if (rects.length === 0) return null;
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -90,6 +100,16 @@ 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
@@ -632,6 +652,8 @@ const __impeccableSnapshot = {
: -1;
const dtr = __snapDirectTextRect(el);
if (dtr) rec.d = dtr;
const tr = __snapTextRects4(el);
if (tr.length) rec.dl = tr;
if (el.isContentEditable) rec.e = true;
if (el.hidden) rec.h = true;
if (typeof el.id !== 'string') rec.i = true;
@@ -661,6 +683,7 @@ const __impeccableSnapshot = {
}
const snapshot = {
v: 1,
textLines: true,
hostname: location.hostname,
quirks: document.compatMode === 'BackCompat',
innerWidth: window.innerWidth,
+57 -1
View File
@@ -1370,7 +1370,14 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
let palette = ec::check_element_ai_palette_dom(dom, el);
palette_tells.extend(palette.tells.iter().copied());
// An ignored subtree gets no vote in the page-wide reading. A cyan
// tell inside `data-impeccable-ignore="ai-color-palette"` would
// otherwise open the two-hue gate and charge neon ink somewhere else
// on the page that nobody waived — ignored content changing the
// result for content that was not ignored.
if !scoped_ignore_active(dom, el, "ai-color-palette") {
palette_tells.extend(palette.tells.iter().copied());
}
if let Some(ink) = palette.ink {
palette_ink.push((el, BrowserFinding::new(ink.id, ink.snippet)));
}
@@ -1931,6 +1938,55 @@ mod tests {
assert_eq!(html_pattern_query(".a::before,"), Some(".a".to_string()));
}
/// An ignored subtree does not get to open the page-wide palette gate.
/// `ai-color-palette` holds neon ink until a second tell hue turns up
/// somewhere on the page; a cyan tell inside a
/// `data-impeccable-ignore="ai-color-palette"` subtree used to count
/// toward that, so waiving one component charged an unrelated one.
#[test]
fn ignored_colors_do_not_contribute_tell_hues() {
let build = |ignore: bool| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
d.set_style(body, "backgroundColor", "rgb(5, 6, 10)");
// The waived component: cyan neon ink on near-black.
let demo = d.add(Some(body), "div");
d.set_style(demo, "backgroundColor", "rgb(5, 6, 10)");
if ignore {
d.set_attr(demo, "data-impeccable-ignore", "ai-color-palette");
}
let cyan = d.add(Some(demo), "span");
d.add_text(cyan, "Terminal output");
d.set_style(cyan, "color", "rgb(34, 238, 238)");
d.set_style(cyan, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(cyan).check_visibility = Some(true);
// Somewhere else on the page, and waived by nobody.
let card = d.add(Some(body), "div");
d.set_style(card, "backgroundColor", "rgb(5, 6, 10)");
let purple = d.add(Some(card), "span");
d.add_text(purple, "Upgrade");
d.set_style(purple, "color", "rgb(180, 60, 245)");
d.set_style(purple, "backgroundColor", "rgba(0, 0, 0, 0)");
d.el_mut(purple).check_visibility = Some(true);
d
};
let charged = |d: &FakeDom| -> Vec<String> {
collect_browser_findings(d, &BrowserConfig::default())
.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.filter(|t| t == "ai-color-palette")
.collect()
};
// Two tell hues, neither waived: the palette is the page's.
assert_eq!(charged(&build(false)).len(), 2);
// The cyan half waived: one tell hue is an accent, and the purple ink
// outside the ignored subtree is not charged either.
assert!(charged(&build(true)).is_empty());
}
#[test]
fn scoped_ignore_and_visual_merge() {
let mut d = FakeDom::new();
+128 -51
View File
@@ -92,32 +92,21 @@ pub fn has_meaningful_direct_text(dom: &dyn Dom, el: ElId) -> bool {
has_direct_text_longer_than(dom, el, 4)
}
/// The width of every line the element's own text rendered on.
/// The width of every line the element's text rendered on, or `None` when
/// the DOM cannot say where the lines are.
///
/// `Range.getClientRects()` gives one rect per line box, so the browser probe
/// hands the lines over as they are. A probe that can only merge them (a
/// captured snapshot) hands over the union, and the union is divided by the
/// line box to get its line count back — either way the caller reads lines
/// and never a box.
fn rendered_line_widths(dom: &dyn Dom, el: ElId, line_box: f64) -> Vec<f64> {
let mut widths: Vec<f64> = Vec::new();
for r in dom.direct_text_line_rects(el) {
if r.width <= 0.0 || r.height <= 0.0 {
continue;
}
// Capped: a line box a stylesheet has shrunk to a fraction of the
// glyphs would otherwise turn one paragraph into thousands of lines,
// and no measure is read off a number that large anyway.
let lines = if line_box > 0.0 {
js::math_min(500.0, js::math_max(1.0, math_round(r.height / line_box)))
} else {
1.0
};
for _ in 0..(lines as usize) {
widths.push(r.width);
}
}
widths
/// `Dom::text_line_rects` has already merged the fragments of a line back
/// together, so each rect here is one line box and nothing is divided by
/// anything: a leading tighter than the glyph box used to turn one rect into
/// two identical "lines" and charge a single long line twice.
fn rendered_line_widths(dom: &dyn Dom, el: ElId) -> Option<Vec<f64>> {
Some(
dom.text_line_rects(el)?
.into_iter()
.filter(|r| r.width > 0.0 && r.height > 0.0)
.map(|r| r.width)
.collect(),
)
}
/// JS: checks.mjs#textDescendantsFlushSides(el, rect) → [top, right, bottom, left]
@@ -284,10 +273,20 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
// it. `rect.width / (fontSize * 0.5)` is the box's capacity: a paragraph
// sitting in a 1022px column whose text stops at 571px was charged with
// 142 characters a line it never rendered (REN-402). What the reader sees
// is `direct_text_line_rects`, one rect per line box, and the characters
// divide between the lines in proportion to the ink each carries — one
// element's text is one font at one size, so the average advance is the
// same on every line of it.
// is `text_line_rects`, one rect per line box with the fragments of a
// line merged back together, and the characters divide between the lines
// in proportion to the ink each carries — one element's text is one font
// at one size, so the average advance is the same on every line of it.
//
// The rects cover the element's whole rendered text, descendants and all,
// which is the same text `text_len` counts: measuring the direct text
// alone and then charging it with the characters of an inline `<strong>`
// inflated every paragraph that had one.
//
// A DOM that cannot say where the lines are gets no finding. The union of
// a long first line and a short tail is the same union as two even lines,
// so there is nothing in it to read a line off, and a rule that guesses
// there is charging noise.
//
// Charged when at least two rendered lines run past the maximum. The harm
// this rule names is the eye losing its place tracking back to the start
@@ -298,28 +297,25 @@ pub fn check_quality(dom: &dyn Dom, q: &QualityInput) -> Vec<RuleHit> {
&& rect.width > 0.0
&& (text_len as f64) > line_max
{
let line_box = match q.line_height_px {
Some(px) if px > 0.0 => px,
_ => font_size * 1.2,
};
let widths = rendered_line_widths(dom, el, line_box);
let total: f64 = widths.iter().sum();
if total > 0.0 {
let over = line_max + 5.0;
let chars = |w: f64| (text_len as f64) * w / total;
let long = widths.iter().filter(|w| chars(**w) > over).count();
if long >= 2 {
let longest = widths.iter().copied().fold(0.0, js::math_max);
findings.push(RuleHit::new(
"line-length",
format!(
"~{} chars on {} of {} rendered lines (aim for <{})",
number_to_string(math_round(chars(longest))),
number_to_string(long as f64),
number_to_string(widths.len() as f64),
number_to_string(line_max)
),
));
if let Some(widths) = rendered_line_widths(dom, el) {
let total: f64 = widths.iter().sum();
if total > 0.0 {
let over = line_max + 5.0;
let chars = |w: f64| (text_len as f64) * w / total;
let long = widths.iter().filter(|w| chars(**w) > over).count();
if long >= 2 {
let longest = widths.iter().copied().fold(0.0, js::math_max);
findings.push(RuleHit::new(
"line-length",
format!(
"~{} chars on {} of {} rendered lines (aim for <{})",
number_to_string(math_round(chars(longest))),
number_to_string(long as f64),
number_to_string(widths.len() as f64),
number_to_string(line_max)
),
));
}
}
}
}
@@ -925,6 +921,87 @@ mod tests {
assert_eq!(hits[0].snippet, "~139 chars on 3 of 4 rendered lines (aim for <80)");
}
/// A line box split across text nodes is still one line. An inline
/// `<strong>` or `<a>` in the middle of a sentence is its own text node,
/// so `getClientRects()` hands back a rect per fragment; counting each
/// fragment as a line divided the paragraph's characters among them and
/// hid a genuinely long column.
#[test]
fn a_line_split_across_fragments_is_one_line() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
// 300 characters over three rendered lines, each interrupted mid-line
// by an inline element and so measured in two pieces.
let p = text_el(&mut d, body, "p", &"w".repeat(300), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
d.set_text_lines(
p,
&[
(0.0, 100.0, 520.0, 19.0),
(520.0, 100.0, 480.0, 19.0),
(0.0, 124.0, 510.0, 19.0),
(510.0, 124.0, 490.0, 19.0),
(0.0, 148.0, 505.0, 19.0),
(505.0, 148.0, 495.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
let line = hits.iter().find(|h| h.id == "line-length").expect("charged");
// Three lines of 1000px, not six of ~500: six would have put 50
// characters on each and charged nothing at all.
assert_eq!(line.snippet, "~100 chars on 3 of 3 rendered lines (aim for <80)");
// The same merge the other way: one long line in two fragments plus a
// short tail is two lines, and one long line is a sentence that
// wrapped once.
let q = text_el(&mut d, body, "p", &"w".repeat(190), "16px");
d.set_rect(q, 0.0, 300.0, 1020.0, 48.0);
d.set_text_lines(
q,
&[
(0.0, 300.0, 600.0, 19.0),
(600.0, 300.0, 400.0, 19.0),
(0.0, 324.0, 120.0, 19.0),
],
);
let hits = check_element_quality_dom(&d, q, &BrowserConfig::default());
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
/// two copies of one long line satisfied "at least two long lines".
#[test]
fn tight_leading_does_not_double_count_a_line() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(200), "15px");
// 10px of leading under an 19px glyph box: `round(19 / 10)` is 2.
d.set_style(p, "lineHeight", "10px");
d.set_rect(p, 0.0, 100.0, 1020.0, 19.0);
d.set_text_lines(p, &[(0.0, 100.0, 1000.0, 19.0)]);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
/// A DOM that kept only the union of its text rects cannot say where the
/// lines are, and the rule stands down rather than inventing them. A
/// snapshot captured before the lines were recorded is that DOM: the
/// union of a long first line and a short tail is the same union as two
/// even lines, so any width read off it is a width nothing rendered.
#[test]
fn a_dom_without_lines_does_not_charge_line_length() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let p = text_el(&mut d, body, "p", &"w".repeat(300), "16px");
d.set_rect(p, 0.0, 100.0, 1020.0, 72.0);
// The same paragraph the merge test charges, measured once.
d.set_text_rect(p, 0.0, 100.0, 1000.0, 67.0);
let hits = check_element_quality_dom(&d, p, &BrowserConfig::default());
assert!(!hits.iter().any(|h| h.id == "line-length"), "{hits:?}");
}
#[test]
fn cramped_padding_vertical() {
let mut d = FakeDom::new();
+145 -7
View File
@@ -43,6 +43,29 @@ re!(
DESIGN_CLASS_SELECTOR,
"^\\.[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`.
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()
);
re!(
DESIGN_NEGATING_BOUNDARY,
r"(?i)^(?:instead of|rather than|as opposed to)$".to_string()
);
// Words that condemn whatever their clause names.
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()
);
// 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()
);
re!(
FONT_SIZE_LITERAL_RE,
format!("^-?[{D}.]+(?:px|rem)$", D = "0-9")
@@ -173,28 +196,115 @@ re!(LEADING_WS_RE, format!("^{WS}*"));
/// Class selectors the design document names as its own, in the order it
/// names them.
///
/// A design document writes a component in backticks — "`No ALL CAPS outside
/// the `.eyebrow` class`" — and that is the repository declaring a pattern by
/// A design document writes a component in backticks — "No ALL CAPS outside
/// the `.eyebrow` class" — and that is the repository declaring a pattern by
/// name. A rule that fires on one of those is reviewing the design system
/// rather than the change, so the browser rules read this list and stand
/// down (REN-406). Only a plain class selector counts: a backticked file
/// name, property or hex is not a component.
///
/// Not every class a document names is a class it sanctions. A document also
/// writes down what it does *not* want — "Avoid `.eyebrow`", "`.card-old` is
/// deprecated", a "Don't" section of counter-examples — and exempting those
/// would silence exactly the misuse the document was written to forbid
/// (REN-406 follow-up). So each occurrence is read in the document's own
/// structure: the heading chain above it, and the clause it sits in. A class
/// condemned anywhere in the document is declared nowhere — a document that
/// says "deprecated" about a class has said enough.
pub fn declared_component_selectors(design_md: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
// The prose is cut into clauses on punctuation, and a code span is full
// of punctuation that is not prose: `.btn-primary` carries a full stop,
// `rgb(0, 0, 0)` a pair of brackets and a comma. Masking every span to a
// run of letters of the same byte length keeps every offset where it was.
let masked = mask_code_spans(design_md);
let mut declared: Vec<String> = Vec::new();
let mut condemned: Vec<String> = Vec::new();
for cap in DESIGN_BACKTICKED.captures_iter(design_md) {
let span = cap.get(0).expect("whole match");
let token = js::trim(cap.get(1).map(|m| m.as_str()).unwrap_or(""));
if !DESIGN_CLASS_SELECTOR.is_match(token) {
continue;
}
let token = token.to_string();
if !out.contains(&token) {
out.push(token);
let list = if design_condemns(&masked, span.start(), span.end()) {
&mut condemned
} else {
&mut declared
};
if !list.contains(&token) {
list.push(token);
}
if out.len() >= 64 {
if declared.len() + condemned.len() >= 128 {
break;
}
}
out
declared.retain(|t| !condemned.contains(t));
declared.truncate(64);
declared
}
/// Every code span replaced by a run of `a` of the same byte length, so the
/// prose around it can be scanned for sentence structure without a class
/// selector's own dot ending a sentence. ASCII in, same length out, so byte
/// offsets into the original still address the same characters.
fn mask_code_spans(design_md: &str) -> String {
let mut masked = design_md.as_bytes().to_vec();
for span in DESIGN_BACKTICKED.find_iter(design_md) {
for b in &mut masked[span.start()..span.end()] {
*b = b'a';
}
}
String::from_utf8(masked).unwrap_or_else(|_| design_md.to_string())
}
/// Whether the document speaks against the code span at `[start, end)`:
/// either it sits under a heading that introduces counter-examples, or its
/// own clause carries a word that condemns what the clause names.
fn design_condemns(masked: &str, start: usize, end: usize) -> bool {
under_negative_heading(masked, start) || clause_condemns(masked, start, end)
}
/// The heading chain above `start`, by level: a subsection of a "Don't"
/// section is still inside it.
fn under_negative_heading(masked: &str, start: usize) -> bool {
let mut chain: Vec<(usize, &str)> = Vec::new();
for line in masked[..start].split('\n') {
let line = line.trim_start();
let level = line.bytes().take_while(|b| *b == b'#').count();
if level == 0 || level > 6 {
continue;
}
let text = &line[level..];
if !text.is_empty() && !text.starts_with(' ') {
continue;
}
chain.retain(|(l, _)| *l < level);
chain.push((level, text));
}
chain
.iter()
.any(|(_, text)| DESIGN_NEGATIVE_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.
fn clause_condemns(masked: &str, start: usize, end: usize) -> bool {
let mut clause_start = 0usize;
let mut opened_by_negation = false;
for boundary in DESIGN_CLAUSE_SPLIT.find_iter(&masked[..start]) {
clause_start = boundary.end();
opened_by_negation = DESIGN_NEGATING_BOUNDARY.is_match(js::trim(boundary.as_str()));
}
if opened_by_negation {
return true;
}
let clause_end = DESIGN_CLAUSE_SPLIT
.find(&masked[end..])
.map(|m| end + m.start())
.unwrap_or(masked.len());
DESIGN_NEGATIVE_WORD.is_match(&masked[clause_start..clause_end])
}
pub fn parse_frontmatter(md: &str) -> Option<Map<String, Value>> {
@@ -1990,6 +2100,33 @@ mod tests {
assert!(declared_component_selectors("nothing to declare").is_empty());
}
/// A document also writes down what it does not want, and exempting those
/// classes silences exactly the misuse the document forbids.
#[test]
fn a_negative_example_declares_nothing() {
// Condemned in its own clause, by several spellings.
let md = "- Avoid `.eyebrow`; it shouts.\n- `.card-old` is deprecated.\n - Use `.kicker` instead of `.eyebrow-legacy`.\n - Every section label is a `.kicker`, not a `.tagline`.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// A section of counter-examples, and its subsections with it.
let md = "## Components\n\n- The label above a heading is `.kicker`.\n\n ## Don't\n\n### Labels\n\n- `.eyebrow` anywhere.\n";
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
// Condemned once is condemned: a class the document calls deprecated
// is not rescued by a list that also names it.
let md = "- Buttons: `.btn-primary`, `.btn-old`.\n- `.btn-old` is deprecated.\n";
assert_eq!(declared_component_selectors(md), vec![".btn-primary"]);
// 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.
let md = "- No Title Case, no ALL CAPS outside the `.eyebrow` class.\n - **Button.** Four kinds and no more: `.btn-primary` (one per\n surface), `.btn-secondary`, `.btn-ghost`, `.btn-danger`.\n";
assert_eq!(
declared_component_selectors(md),
vec![".eyebrow", ".btn-primary", ".btn-secondary", ".btn-ghost", ".btn-danger"]
);
}
// ── #570 monorepo DESIGN.md inheritance ─────────────────────────────────
// Mirrors tests/detect-cli-design-monorepo.test.mjs (public repo main,
// 47e41195 + 5d7c1cce + e975bec4 + 91f2c7b4) at the findDesignRoot level.
@@ -2325,3 +2462,4 @@ mod tests {
assert_eq!(js_string(&parse_scalar("007")), "7");
}
}
+61 -9
View File
@@ -168,19 +168,71 @@ pub trait Dom {
/// of every non-blank direct text node (rects narrower/shorter than 1px
/// dropped); `None` when there is none.
fn direct_text_rect(&self, el: ElId) -> Option<Rect>;
/// The same client rects, unmerged: one per line the text actually
/// rendered on (`Range.getClientRects()` returns a rect per line box),
/// in document order.
/// The rows the element's rendered text occupies: one rect per line box,
/// top to bottom. `None` when this DOM cannot say where the lines are.
///
/// This is how a rule reads a line rather than the box that holds it. The
/// default answers the union as a single rect, which is what a probe that
/// cannot split a wrapped run has; a caller that needs the count divides
/// the rect by the line box rather than assuming one line.
fn direct_text_line_rects(&self, el: ElId) -> Vec<Rect> {
self.direct_text_rect(el).into_iter().collect()
/// This is how a rule reads a line rather than the box that holds it, and
/// a line here is the whole line the reader sees. `getClientRects()` on a
/// text node gives a rect per line box, but a line box is routinely split
/// across several text nodes — an inline `<strong>` in the middle of a
/// sentence, a framework marker, an HTML comment — so the rects are
/// collected over the element's whole rendered text (descendants
/// included, which is the text `text_content` counts) and the ones that
/// share a row are merged back into the one line they came from. Without
/// that merge each fragment is a "line" and one wrapped sentence is
/// charged as several.
///
/// `None` is the honest answer from a DOM that only kept the union of
/// those rects (a page snapshot captured before the lines were recorded).
/// A caller stands down there; it never divides a union by a line height
/// and calls the pieces lines, because the union of a long first line and
/// a short tail says nothing about either.
fn text_line_rects(&self, _el: ElId) -> Option<Vec<Rect>> {
None
}
}
/// 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.
///
/// 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.
pub fn merge_text_rects_into_lines(rects: Vec<Rect>) -> Vec<Rect> {
let mut rects: Vec<Rect> = rects
.into_iter()
.filter(|r| r.width > 0.0 && r.height > 0.0 && r.all_finite())
.collect();
rects.sort_by(|a, b| {
a.top
.partial_cmp(&b.top)
.unwrap_or(std::cmp::Ordering::Equal)
.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.
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 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);
continue;
}
}
bands.push((r.top, r.bottom));
lines.push(r);
}
lines
}
// ── shared helpers over the trait ─────────────────────────────────────────
/// `el.tagName.toLowerCase()`.
+13 -13
View File
@@ -35,8 +35,9 @@ pub struct FakeEl {
pub hidden: bool,
pub check_visibility: Option<bool>,
pub direct_text_rect: Option<Rect>,
/// The per-line rects of the direct text; empty falls back to the union.
pub direct_text_line_rects: Vec<Rect>,
/// The rects of the element's rendered text. `None` is a DOM that cannot
/// say where the lines are, the way a snapshot without them cannot.
pub text_line_rects: Option<Vec<Rect>>,
/// Selectors (exact strings) this element matches beyond `*` and its tag.
pub selectors: Vec<String>,
/// `id` IDL property override (`None` = "not a string", falls back to attr).
@@ -160,13 +161,16 @@ impl FakeDom {
self.el_mut(id).rect = Rect::from_xywh(x, y, w, h);
self
}
/// The union rect of `id`'s direct text, as `getClientRects()` would give it.
/// The union rect of `id`'s direct text, and nothing about its lines:
/// a DOM that measured the text once, the way a page snapshot captured
/// before the lines were recorded did.
pub fn set_text_rect(&mut self, id: ElId, x: f64, y: f64, w: f64, h: f64) -> &mut Self {
self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(x, y, w, h));
self
}
/// The rects of `id`'s direct text, one per rendered line. The union is
/// derived from them, so a test declares the lines and nothing else.
/// The rects of `id`'s rendered text. The union is derived from them, so a
/// test declares what rendered and nothing else. Fragments that share a
/// row merge into one line on the way out, exactly as a live page's do.
pub fn set_text_lines(&mut self, id: ElId, lines: &[(f64, f64, f64, f64)]) -> &mut Self {
let rects: Vec<Rect> = lines
.iter()
@@ -179,7 +183,7 @@ impl FakeDom {
let bottom = rects.iter().map(|r| r.bottom).fold(f64::NEG_INFINITY, f64::max);
self.el_mut(id).direct_text_rect = Some(Rect::from_xywh(left, top, right - left, bottom - top));
}
self.el_mut(id).direct_text_line_rects = rects;
self.el_mut(id).text_line_rects = Some(rects);
self
}
pub fn add_text(&mut self, id: ElId, text: &str) -> &mut Self {
@@ -527,12 +531,8 @@ impl Dom for FakeDom {
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.els[el as usize].direct_text_rect
}
fn direct_text_line_rects(&self, el: ElId) -> Vec<Rect> {
let lines = &self.els[el as usize].direct_text_line_rects;
if lines.is_empty() {
self.direct_text_rect(el).into_iter().collect()
} else {
lines.clone()
}
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
let rects = self.els[el as usize].text_line_rects.clone()?;
Some(super::dom::merge_text_rects_into_lines(rects))
}
}
+60
View File
@@ -273,6 +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.
#[serde(rename = "dl", default, skip_serializing_if = "Vec::is_empty")]
pub text_rects: Vec<[f64; 4]>,
#[serde(rename = "e", default)]
pub content_editable: bool,
#[serde(rename = "h", default)]
@@ -374,6 +382,12 @@ pub struct Snapshot {
pub body: Option<u32>,
#[serde(rename = "bodyInnerText", default)]
pub body_inner_text: Option<String>,
/// Whether this capture recorded the rects of each element's rendered
/// text (`SnapNode::text_rects`). False in captures older than that
/// change, where the union in `d` is all there is: a rule that needs the
/// lines stands down rather than inventing them from the union.
#[serde(rename = "textLines", default)]
pub text_lines: bool,
#[serde(default)]
pub hits: Vec<HitTest>,
/// Derived on load: column index per style property name.
@@ -911,6 +925,18 @@ impl Dom for SnapshotDom {
fn direct_text_rect(&self, el: ElId) -> Option<Rect> {
self.snap.node(el).direct_text_rect.as_ref().map(rect4)
}
/// `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
/// height would invent widths that nothing on the page rendered.
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
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(),
))
}
}
/// `undefined` read into a wasm f64 is NaN (`offsetWidth` on an SVG
@@ -1087,6 +1113,40 @@ mod tests {
assert!(!d.has_needs());
}
/// A capture that recorded the text rects hands over the lines; one that
/// did not says so, and the caller stands down rather than reading lines
/// out of a union.
#[test]
fn text_lines_come_from_the_capture_or_not_at_all() {
const OLD: &str = r#"{
"v": 1, "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]}
]
}"#;
assert_eq!(snap(OLD).text_line_rects(3), None);
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]]}
]
}"#;
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()));
}
#[test]
fn css_escape_matches_spec() {
assert_eq!(css_escape("foo"), "foo");
+3 -3
View File
@@ -33,7 +33,7 @@
"id": "ai-color-palette",
"name": "AI color palette",
"category": "slop",
"description": "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette."
"description": "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. A gradient in one of those hues is the tell on its own; flat neon ink on a dark ground is charged once a second tell hue joins it. Choose a distinctive, intentional palette."
},
{
"id": "cream-palette",
@@ -231,13 +231,13 @@
"id": "line-length",
"name": "Line length too long",
"category": "quality",
"description": "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers."
"description": "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line, so it is measured on the lines that rendered and charged when more than one of them runs long. Add a max-width (65ch to 75ch) to text containers."
},
{
"id": "cramped-padding",
"name": "Cramped padding",
"category": "quality",
"description": "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers."
"description": "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the space between the rendered text and the border box is too small for the font size, and (2) a wrapper whose children's text lands flush against a visible boundary (border, outline, or non-transparent background) with nothing to inset it. Add at least 8px (ideally 1216px) of space inside bordered, outlined, or colored containers."
},
{
"id": "body-text-viewport-edge",
File diff suppressed because one or more lines are too long
+13 -9
View File
@@ -5,7 +5,9 @@
//! (`u32::MAX`, or a first element of `u32::MAX` in arrays) because the probe
//! catches the DOM's SyntaxError and cannot throw across the boundary.
use impeccable_core::browser::dom::{Dom, ElId, KeyframeFrame, Rect, SelectorError};
use impeccable_core::browser::dom::{
merge_text_rects_into_lines, Dom, ElId, KeyframeFrame, Rect, SelectorError,
};
use std::cell::RefCell;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
@@ -58,7 +60,7 @@ extern "C" {
fn offset_height(el: u32) -> f64;
fn check_visibility(el: u32) -> i32;
fn direct_text_rect(el: u32) -> Vec<f64>;
fn direct_text_line_rects(el: u32) -> Vec<f64>;
fn text_rects(el: u32) -> Vec<f64>;
}
fn opt(id: u32) -> Option<ElId> {
@@ -341,12 +343,14 @@ impl Dom for JsDom {
Some(to_rect(&v))
}
}
/// The probe flattens the per-line rects into one array of eights, in the
/// order `rect` uses; a tail shorter than a rect is ignored.
fn direct_text_line_rects(&self, el: ElId) -> Vec<Rect> {
direct_text_line_rects(el)
.chunks_exact(8)
.map(to_rect)
.collect()
/// A live page can always say where its lines are. The probe flattens the
/// rects of every text node under the element into one array of eights, in
/// the order `rect` uses (a tail shorter than a rect is ignored), and the
/// fragments that share a row are merged back into the line they came
/// from here.
fn text_line_rects(&self, el: ElId) -> Option<Vec<Rect>> {
Some(merge_text_rects_into_lines(
text_rects(el).chunks_exact(8).map(to_rect).collect(),
))
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long