From 8bda7a0fdfe1794ecfd5f519590d0b0a7c44dd2a Mon Sep 17 00:00:00 2001 From: Karan Date: Sun, 25 Oct 2026 01:32:56 -0400 Subject: [PATCH 1/3] Visual checks ad-heavy and text-warped; navigation paths in interaction analytics ad-heavy (VISUAL.md 3.24): the share of the first screen and of the page that ad slots cover (named by id, class or ad attributes, or cross-site frames at fixed ad sizes), against the Coalition for Better Ads thresholds, plus fixed and interstitial ads. text-warped (3.25): overlapping line boxes, lines cut by their overflow box, lone surrogates and unassigned code points, mirrored or skewed text, letter spacing that splits words, text drawn below 8 px. Both are kind "check", report only, in the lazy chunk's idle slices, never model input. Interaction analytics (ANALYTICS.md 11): how each view was reached, the previous route and the time between, the first interaction per view, and at pagehide the exit and the ordered path; collector counters and a dashboard section. Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 15 +- README.md | 2 +- bench/visual/README.md | 11 +- bench/visual/faults.ts | 132 +++- bench/visual/run.mjs | 13 +- docs/runtime/ANALYTICS.md | 67 ++ docs/runtime/API.md | 10 +- docs/runtime/VISUAL.md | 149 ++++- packages/runtime/CHANGELOG.md | 20 + packages/runtime/README.md | 8 +- packages/runtime/TELEMETRY.md | 18 +- packages/runtime/src/analytics/index.ts | 134 +++- packages/runtime/src/types.ts | 16 +- packages/runtime/src/visual/checks/ads.ts | 286 +++++++++ packages/runtime/src/visual/checks/warped.ts | 581 ++++++++++++++++++ packages/runtime/src/visual/engine.ts | 15 +- packages/runtime/test/analytics.test.ts | 128 +++- .../visual/fixtures/ads-interstitial.html | 26 + .../test/browser/visual/fixtures/ads.html | 41 ++ .../visual/fixtures/clean-strange.html | 81 +++ .../test/browser/visual/fixtures/warped.html | 48 ++ .../test/browser/visual/fixtures/warped.js | 2 + .../test/browser/visual/visual.spec.ts | 49 ++ packages/runtime/test/visual-checks.test.ts | 6 +- packages/runtime/test/visual-strange.test.ts | 186 ++++++ telemetry-worker/README.md | 5 +- telemetry-worker/src/dashboard.ts | 46 +- telemetry-worker/src/pages.ts | 80 ++- telemetry-worker/src/sample.ts | 48 ++ telemetry-worker/test/dashboard.test.ts | 27 + telemetry-worker/test/pages.test.ts | 27 + 31 files changed, 2225 insertions(+), 52 deletions(-) create mode 100644 packages/runtime/src/visual/checks/ads.ts create mode 100644 packages/runtime/src/visual/checks/warped.ts create mode 100644 packages/runtime/test/browser/visual/fixtures/ads-interstitial.html create mode 100644 packages/runtime/test/browser/visual/fixtures/ads.html create mode 100644 packages/runtime/test/browser/visual/fixtures/clean-strange.html create mode 100644 packages/runtime/test/browser/visual/fixtures/warped.html create mode 100644 packages/runtime/test/browser/visual/fixtures/warped.js create mode 100644 packages/runtime/test/visual-strange.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index d4639f0c..7053b631 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # GenClass Runtime: privacy policy -_Last updated: 2026-10-22._ +_Last updated: 2026-10-25._ This policy covers the diagnostics that `@genclass/runtime` (from `0.1.0-beta.3`) sends to its maintainers. It does not cover the website or app that uses GenClass; that site has its own privacy policy. @@ -45,9 +45,14 @@ object; version 0.2.1 sent from those hosts by default, by mistake, fixed in 0.2 the page the visitor scrolled (25, 50, 75 or 100 %), and how many times each element was clicked, clicked 3 or more times within a second, or clicked without anything changing on the page. An element is named from the page's code (a name the developer gave it, an id, a link's address on the same site, the other site's address only, class names - or its position on the page), never by its text. We do not collect what visitors type, where the mouse was, the - order of clicks, or anything that links one page load to another. We use this data only to show it on that - website's dashboard and in its export, never to train or evaluate the model. + or its position on the page), never by its text. From the runtime release after 0.3.0-beta.1, also how the visitor + moved within one page load: how each page view was reached (a page load, a reload, the browser's back or forward + button, a link inside the site), the previous page's path and the time spent before moving on, the element they + first clicked or (with the keyboard) first moved to on each page and how long that took, and, when the page load + ends, the paths of its page views in order (at most 10) and where it ended. We do not collect what visitors type, + where the mouse was, the order of clicks, where the visitor came from (the referrer), or anything that links one + page load to another. We use this data only to show it on that website's dashboard and in its export, never to + train or evaluate the model. We do **not** store IP addresses, browser user-agent strings or cookies, and the runtime sets no cookies. Each page load gets a random session ID that is not saved anywhere. @@ -61,7 +66,7 @@ works on their own site. We do not sell the data, use it for advertising, or try A website owner can get a private dashboard link for their site's token. Whoever holds that link can see aggregated diagnostics for that site (counts of decisions, detections, possible detections and actions, the site's hostnames, routes, the names the developer gave to protected functions, model speed, versions; with interaction analytics on, -page views, time on page, scroll depth and clicks per page and element) and a list of recent detections +page views, the paths between pages, time on page, scroll depth and clicks per page and element) and a list of recent detections (time, route or function name, diagnosis, action). Dashboards do not show the situation text. Data from browsers that opted out or send Global Privacy Control never reaches a dashboard. diff --git a/README.md b/README.md index 31ef392d..b695d86d 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ export const submitOrder = protect("checkout submit", (cart) => fetch("/api/orde - **The measured visual checks send nothing** (new in `0.3.0`): their findings stay in the page (the console, `rt.checks()`, your own sinks). - **The situation text the model read is sent only if you opt in** with `GenClass.init({ telemetry: { include: { situation: true } } })`. It is redacted by field meaning, but can still contain route names with query strings, short error messages and short field values; read [TELEMETRY.md](packages/runtime/TELEMETRY.md) before enabling it. - **Opt out:** `GenClass.init({ telemetry: false })`, `?genclass=no-telemetry`, `localStorage.setItem("genclass.telemetry", "off")`; browsers sending Global Privacy Control are never collected. -- **Interaction analytics are off unless the app turns them on** (`analytics: true` with the app's token, new in `0.3.0-beta.1`): per page view the route, engaged time, scroll depth, and clicks, rage clicks and dead clicks per element, the element named from markup, never its text. No visitor id, nothing stored on the device, every opt-out above applies, and they never feed a decision. Where analytics need consent (EU, EEA, UK), start them after consent with `runtime.analytics.start()`. See [TELEMETRY.md](packages/runtime/TELEMETRY.md#interaction-analytics). +- **Interaction analytics are off unless the app turns them on** (`analytics: true` with the app's token, new in `0.3.0-beta.1`): per page view the route, engaged time, scroll depth, and clicks, rage clicks and dead clicks per element, the element named from markup, never its text; and, within one page load, how each page view was reached, the route before it and the time between, the first element used on it, and where the page load ended with its routes in order. No visitor id, nothing stored on the device, every opt-out above applies, and they never feed a decision. Where analytics need consent (EU, EEA, UK), start them after consent with `runtime.analytics.start()`. See [TELEMETRY.md](packages/runtime/TELEMETRY.md#interaction-analytics). - **With a token** (`0.3.0`: `token` option, `data-token`, `token=` in the meta tag), each batch also carries your app's public token, so the same diagnostics also show on your app's private dashboard (whoever holds its link can open it), and decisions about `protect()`ed functions carry the function's name (`fn`, the label you chose). diff --git a/bench/visual/README.md b/bench/visual/README.md index 69f39b5c..d2dd9494 100644 --- a/bench/visual/README.md +++ b/bench/visual/README.md @@ -16,7 +16,9 @@ It measures: (desktop and phone, light, and dark where the site has a dark design), no clicks, no forms, no crawling. Bot challenges and failed loads are "not measured", never clean; - **recall on planted faults**: faults applied from the harness (`faults.ts`) to the demos, templates and compat apps, - one group per load, with a check afterwards that each fault really took; + one group per load, with a check afterwards that each fault really took (since 2026-10-25 also `T1`, seven + `text-warped` faults on distinct text elements, and `ad-heavy`, full-width ad slots at the top of the page until they + are 40% of it; `--faults T1,ad-heavy` runs only the groups named); - **cost**: check time, longest slice and elements read for the first pass, at 1x and at 4x CPU throttling; - **the real integration**: on the compat apps, `window.GENCLASS_CONFIG = { visual: true }` with no harness, for the stub's timing (the chunk's size is not measured here: resource timing reports it as 0 in these loads, so @@ -85,5 +87,8 @@ gh run download --repo genclass-dev/GenClass-lib --name visual-summary ``` On the visual branch, a push that changes `bench/visual/` starts it (`[smoke]` in the commit message: two pages per -group). Locally, only `summarize.mjs` runs: `node bench/visual/summarize.mjs --labels -bench/visual/labels.json`. +group). Locally, `summarize.mjs` runs on downloaded artifacts: `node bench/visual/summarize.mjs +--labels bench/visual/labels.json`. The demos and templates groups also run on a machine with Chromium and 16 GB +(2026-10-25, RESULTS.md section 21): `node bench/visual/build.mjs`, `node bench/visual/corpus.mjs prepare demos` +(templates: with `VISUAL_BENCH_WORK=`, which `npm install`s each starter), then `node bench/visual/run.mjs +--group demos --parts clean,cost,faults`. The public sites run only in the workflow. diff --git a/bench/visual/faults.ts b/bench/visual/faults.ts index c196c65d..cfd588ba 100644 --- a/bench/visual/faults.ts +++ b/bench/visual/faults.ts @@ -9,11 +9,14 @@ // text-template-leak ([object Object], $NaN), text-encoding, text-placeholder // A2 an inserted box with image-broken, image-distorted, image-css-broken, media-failed, icon-font-failed, // stylesheet-failed, csp-blocked +// T1 text-warped (2026-10-25): line-height that stacks lines, a line cut by a narrow overflow box, half an emoji, +// mirrored, skewed, letters spaced wider than words, 6 px text // Page faults (one per load): page-overflow-x, pointer-events-leak, scroll-lock-stuck, click-blocked, -// control-unreachable, focus-obscured, dialog-covered, layout-shift; at document start: page-blank, -// viewport-meta-missing. +// control-unreachable, focus-obscured, dialog-covered, layout-shift, ad-heavy (2026-10-25: ad slots inserted at the +// top of the page until they are 40% of it); at document start: page-blank, viewport-meta-missing. import { contrast, luminance, parseColor, toHex, type RGBA } from "../../packages/runtime/src/visual/math.js"; +import { unionArea } from "../../packages/runtime/src/visual/checks/ads.js"; export interface FaultResult { kind: string; @@ -511,6 +514,74 @@ export function inject(kind: string, seed: number, extra: Record { + const t = firstText(el); + if (!t) return 0; + const rg = document.createRange(); + rg.selectNodeContents(t); + return new Set([...rg.getClientRects()].filter((x) => x.width > 0).map((x) => Math.round(x.top))).size; + }; + const words = (el: Element) => ((firstText(el)?.data ?? "").match(/\p{L}{2,}/gu) ?? []).length; + let cands = textCands(how === "tofu").filter((el) => !pinned(el) && !translucentOrFiltered(el)); + if (how === "overlap") cands = cands.filter((el) => lines(el) >= 2); + if (how === "spacing") cands = cands.filter((el) => words(el) >= 2); + if (how === "clipped") cands = cands.filter((el) => (firstText(el)?.data.trim().length ?? 0) >= 12); + const el = pick(cands, r); + if (!el) return fail(`no visible text element in the viewport for ${how}`); + if (how === "tofu") { + const span = document.createElement("span"); + span.textContent = " Great deal \ud83d"; + el.appendChild(span); + span.setAttribute("data-bench-fault", base); + span.setAttribute("data-bench-id", id); + hosts.add(el); + planted.set(id, { el: span, extra: { how } }); + return { kind: base, id, ok: true, info: { how } }; + } + const inline = getComputedStyle(el).display === "inline"; + if (how === "overlap") setImp(el, { "line-height": "0.3" }); + else if (how === "clipped") { + const w = Math.max(40, Math.floor(el.getBoundingClientRect().width / 2)); + setImp(el, { display: "inline-block", "white-space": "nowrap", overflow: "hidden", "text-overflow": "clip", width: `${w}px`, "max-width": `${w}px`, "vertical-align": "top" }); + } else if (how === "mirrored") setImp(el, { ...(inline ? { display: "inline-block" } : {}), transform: "scaleX(-1)", transition: "none" }); + else if (how === "skewed") setImp(el, { ...(inline ? { display: "inline-block" } : {}), transform: "skewX(30deg)", transition: "none" }); + else if (how === "spacing") setImp(el, { "letter-spacing": "0.8em" }); + else setImp(el, { "font-size": "6px", "line-height": "1.2" }); + mark(el, base, id); + planted.set(id, { el, extra: { how } }); + return { kind: base, id, ok: true, info: { how, tag: el.tagName.toLowerCase() } }; + } case "layout-shift": { const d = document.createElement("div"); setImp(d, { display: "block", height: "60vh", width: "100%" }); @@ -691,6 +762,63 @@ export function truth(id: string, B: BenchState): { ok: boolean; why: string; va } return { ok: true, why: "" }; } + case "ad-heavy": { + const slots = [...el.children].filter((c) => visible(c)); + const vw = document.documentElement.clientWidth; + const ph = Math.max(document.documentElement.scrollHeight, innerHeight); + const share = unionArea(slots.map((c) => { + const rc = c.getBoundingClientRect(); + return { left: rc.left, top: rc.top + scrollY, right: rc.right, bottom: rc.bottom + scrollY }; + }), { left: 0, top: 0, right: vw, bottom: ph }) / (vw * ph); + return share >= 0.3 ? { ok: true, why: "", value: Math.round(share * 100) / 100 } : no(`the slots cover ${Math.round(share * 100)}% of the page`); + } + case "text-warped": { + if (!el.isConnected || !visible(el) || !intersects(el)) return no("the element left the viewport or the page"); + const how = p.extra?.how; + const s = getComputedStyle(el); + const fs = parseFloat(s.fontSize) || 16; + const t = firstText(el); + const rects = (() => { + if (!t) return []; + const rg = document.createRange(); + rg.selectNodeContents(t); + return [...rg.getClientRects()].filter((x) => x.width > 0 && x.height > 0); + })(); + switch (how) { + case "overlap": { + let best = 0; + for (let i = 0; i + 1 < rects.length; i++) { + const a = rects[i]; + const b = rects[i + 1]; + if (Math.min(a.right, b.right) - Math.max(a.left, b.left) < 8) continue; + best = Math.max(best, (Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)) / Math.min(a.height, b.height)); + } + return best >= 0.5 ? { ok: true, why: "", value: Math.round(best * 100) / 100 } : no(`lines overlap by ${Math.round(best * 100)}%`); + } + case "clipped": { + const box = el.getBoundingClientRect(); + const a = rects[0]; + return a && a.right > box.right + 4 && a.left < box.right - 4 ? { ok: true, why: "", value: Math.round(a.right - box.right) } : no("the first line is not cut"); + } + case "tofu": + return /[\ud800-\udfff]/u.test(el.textContent ?? "") ? { ok: true, why: "" } : no("the lone surrogate is gone"); + case "mirrored": + case "skewed": { + const m = new DOMMatrixReadOnly(s.transform === "none" ? undefined : s.transform); + const det = m.a * m.d - m.b * m.c; + if (how === "mirrored") return det < 0 ? { ok: true, why: "" } : no("not mirrored"); + return Math.abs(m.c) > Math.tan((20 * Math.PI) / 180) ? { ok: true, why: "" } : no("not skewed above 20 degrees"); + } + case "spacing": { + const ls = parseFloat(s.letterSpacing) / fs; + return ls >= 0.5 ? { ok: true, why: "", value: Math.round(ls * 100) / 100 } : no(`letter-spacing ${ls.toFixed(2)} em`); + } + case "tiny": + return fs < 8 && rects.some((x) => x.height < 10) ? { ok: true, why: "", value: fs } : no(`font-size ${fs} px`); + default: + return no("unknown text-warped fault"); + } + } case "viewport-meta-missing": { const sw = screen.width; const cw = document.documentElement.clientWidth; diff --git a/bench/visual/run.mjs b/bench/visual/run.mjs index b4a14a8f..dd7bdbbd 100644 --- a/bench/visual/run.mjs +++ b/bench/visual/run.mjs @@ -3,7 +3,7 @@ // (.github/workflows/visual-measure.yml): it starts Chromium, which never runs on a developer machine here. // // node bench/visual/run.mjs --group demos|templates|compat|sites [--shard 0/4] [--parts clean,cost,faults,integration] -// [--workers 2] [--seed 1] [--out ] +// [--workers 2] [--seed 1] [--out ] [--faults T1,ad-heavy] // // Loads, per page: // clean desktop 1280 x 800 and phone 390 x 844, light and dark (public sites: dark only where the site has a @@ -56,14 +56,17 @@ const VIEWPORTS = { const GROUPS = { A1: ["text-invisible:equal", "text-invisible:1.3", "font-failed", "css-var-undefined", "text-template-leak:object", "text-template-leak:nan", "text-encoding", "text-placeholder"], A2: ["image-broken", "image-distorted", "image-css-broken", "media-failed", "icon-font-failed", "stylesheet-failed", "csp-blocked"], + T1: ["text-warped:overlap", "text-warped:clipped", "text-warped:tofu", "text-warped:mirrored", "text-warped:skewed", "text-warped:spacing", "text-warped:tiny"], }; const FAULTS = { - desktop: ["A1", "A2", "page-overflow-x", "pointer-events-leak", "scroll-lock-stuck", "click-blocked", "control-unreachable", "focus-obscured", "dialog-covered", "layout-shift", "page-blank"], - phone: ["A1", "A2", "viewport-meta-missing", "page-overflow-x", "control-unreachable", "scroll-lock-stuck"], + desktop: ["A1", "A2", "T1", "page-overflow-x", "pointer-events-leak", "scroll-lock-stuck", "click-blocked", "control-unreachable", "focus-obscured", "dialog-covered", "layout-shift", "page-blank", "ad-heavy"], + phone: ["A1", "A2", "T1", "viewport-meta-missing", "page-overflow-x", "control-unreachable", "scroll-lock-stuck", "ad-heavy"], }; +/** --faults a,b: only these fault groups (a local run of the new checks' recall); default all. */ +const ONLY = opt("faults", null)?.split(",") ?? null; const EARLY = new Set(["page-blank", "viewport-meta-missing"]); /** Kinds matched by kind alone (the finding names html, body or a moved element, not the planted one). */ -const PAGE_LEVEL = new Set(["pointer-events-leak", "scroll-lock-stuck", "layout-shift", "page-blank", "viewport-meta-missing", "csp-blocked"]); +const PAGE_LEVEL = new Set(["pointer-events-leak", "scroll-lock-stuck", "layout-shift", "page-blank", "viewport-meta-missing", "csp-blocked", "ad-heavy"]); const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a); let publicDone = 0; @@ -586,7 +589,7 @@ async function main() { save(); } if (PARTS.has("faults")) { - const jobs = pages.filter((p) => p.faults).flatMap((spec) => Object.entries(FAULTS).flatMap(([viewport, list]) => list.map((fault) => ({ spec, o: { viewport, fault } })))); + const jobs = pages.filter((p) => p.faults).flatMap((spec) => Object.entries(FAULTS).flatMap(([viewport, list]) => list.filter((fault) => !ONLY || ONLY.includes(fault)).map((fault) => ({ spec, o: { viewport, fault } })))); out.loads.push(...(await pool(jobs, Math.max(WORKERS, 3), (j) => faultLoad(browser, j.spec, j.o)))); save(); } diff --git a/docs/runtime/ANALYTICS.md b/docs/runtime/ANALYTICS.md index 2977a34b..09a91cef 100644 --- a/docs/runtime/ANALYTICS.md +++ b/docs/runtime/ANALYTICS.md @@ -245,3 +245,70 @@ What the build (2026-10-18) does where this note was not specific, or differs: `interactions` events. - **Not done here:** `billing/plans.json` still says "Interaction analytics (coming next)" for Free (every plan has it); it becomes available when a runtime with analytics is on npm. genclass.dev's own pages are unchanged (section 7). + +## 11. Navigation paths (2026-10-25) + +The owner asked on 2026-10-25 for "exact specifics of how a user navigates the site". This widens what interaction +analytics send (AGENTS.md rule 8); the owner asked for it, and it stays inside the same opt-in: nothing of it exists +unless the app sets `analytics: true`, with the app's token, every telemetry opt-out and Global Privacy Control +turning it off, and the same consent advice (section 5). Built on branch `strange-sites`. + +### 11.1 What is added + +Four things per page load, on the existing `interactions` events, aggregated by the collector like views, clicks and +scroll depth (counts per route and per element key, never a stored sequence per visitor): + +| field | on which event | what it is | +|---|---|---| +| `nav` | a view's first event (`start: true`) | how the visitor reached this view: `load` (a normal page load), `reload`, `back_forward` (a page load or a restore from the back/forward cache by the browser's back or forward button, from Navigation Timing's `type` and `pageshow.persisted`), `push` (an in-app route change by `pushState` or `replaceState`, or one seen at the next input when the nav observer is off), `back` or `forward` (an in-app history traversal, `popstate`, the direction read from the Navigation API's entry index where the browser has it), `pop` (a traversal whose direction the browser does not expose) | +| `from`, `fromMs` | a view's first event, from the second view of a page load on | the previous view's route in this page load, and the ms from that view's start to this one's (on the runtime's Clock): the route-to-route step and the time between | +| `first`, `firstMs` | the event after the view's first interaction, once per view | the element key (section 1, the same keys clicks use) of the first click, or of the first focus on a form field (`input`, `select`, `textarea`, an editable region) for keyboard users, and the ms from the view's start | +| `exit`, `path` | the page load's last event (`pagehide`) | `exit: true` marks where the page load ended; `path` lists the routes of its views in order, at most 10 (a longer path ends with `"(more)"`). After a restore from the back/forward cache a new path starts | + +Everything else in section 1 holds: routes are normalised as telemetry sends them (ids and `@` segments as `:id`, no +query, no fragment), element keys come from markup only and never from text, there is no visitor id and nothing is +written to the device. + +What changes in the "Never recorded" list: the **order of page views within one page load** and the time between +them are now recorded (the order and timing of clicks still are not). Two page loads still cannot be linked: a full +page navigation to another page of the same app ends one page load (an exit) and starts another (an entry with +`nav: "load"`), because the referrer is not read and no id is stored. So on a site without client-side routing every +page view is its own path of one, and exits there mean "the visitor left this page", not "left the site". + +Not sent: the referrer, the URL a link pointed to beyond its element key, the history length, the Navigation API's +entry keys or ids, scroll positions per step, the time of day beyond the batch's own timestamp. + +A page load that ends without `pagehide` (a phone that discards a background tab, a crash) sends no `exit` and no +`path`: its views and steps are counted, its end is not. + +### 11.2 Collector and dashboard + +New `ix_*` counters in `summarize()` (the same per-batch budget of 200, no migration): + +| metric | key | count | +|---|---|---| +| `ix_entry` | `''` and the route | views with no `from` (where page loads start) | +| `ix_nav` | from TAB to | route-to-route steps | +| `ix_nav_ms` | from TAB to | the sum of `fromMs` (the dashboard shows the average) | +| `ix_arrive` | `nav`, and route TAB `nav` | views by how they were reached | +| `ix_first` | route TAB element key | views whose first interaction was on that element | +| `ix_first_ms`, `ix_first_n` | the route | the sum of `firstMs` and the views that had a first interaction | +| `ix_exit` | `''` and the route | where page loads ended | +| `ix_path` | the first 5 routes joined with ` > ` (` > …` when longer), at most 240 characters | page loads that took that path | + +Bounds, as for the other fields: `fromMs` and `firstMs` above 24 h are ignored, `nav` outside the list above is +`(other)`, routes are cut to 100 characters and element keys to 120. + +The dashboard's Visitors view gets "How visitors move through this app": entries, exits and back/forward use with the +change on the previous period; Table 7, the most taken paths; Table 8, page-to-page steps with the average time +before the step; Table 9, per page: entries, exits and the exit rate (exits over views), back/forward arrivals, the +most frequent first interaction and the average time to it. The footer's clause becomes "page views, the path between +pages, time on page, scroll depth and clicks per element". + +### 11.3 Disclosures + +`packages/runtime/TELEMETRY.md` (the `interactions` row and the section), `PRIVACY.md`, the console notice +("Interaction analytics are on (this app turned them on): page views and the path between them, time on page, scroll +depth and clicks per element, never page text."), the JSDoc of `InitOptions.analytics`, `docs/runtime/API.md`, +`packages/runtime/CHANGELOG.md` and the dashboard footer say what is added. genclass.dev's privacy page lives in the +site repository and is updated there when the site takes a runtime with this. diff --git a/docs/runtime/API.md b/docs/runtime/API.md index ee013143..3124aae9 100644 --- a/docs/runtime/API.md +++ b/docs/runtime/API.md @@ -71,7 +71,10 @@ tag or `data-token` / `data-scope`). `token` (or a custom `telemetry.endpoint`) also sends `interactions` telemetry events for the app's dashboard: per page view the route, engaged time, scroll milestones, and clicks, rage clicks and dead clicks per element key (built from markup: `data-genclass-name`, `data-testid`, a stable id, a link's target, stable classes, a short structural -path; never element text). Every telemetry opt-out and Global Privacy Control turn them off; without a token +path; never element text), and how visitors move within a page load: how each view was reached (`nav`), the previous +route and the time between (`from`, `fromMs`), the first element interacted with (`first`, `firstMs`), and at the +end of the page load where it ended and its routes in order (`exit`, `path`, at most 10; ANALYTICS.md section 11). +Every telemetry opt-out and Global Privacy Control turn them off; without a token `analytics: true` warns once and records nothing. They run in a lazy chunk (`dist/analytics/index.js`), outside the event log: nothing reaches a situation, a decision or the model. `rt.analytics.start()` / `stop()` turn them on and off later (consent; `stop()` drops the counts not sent yet). Also `data-analytics="on"`, `analytics=on` in the meta @@ -123,7 +126,8 @@ interface InitOptions { // off in GenClass.init / createRuntime (there it needs import "@genclass/runtime/discover" first) token?: string; // (0.3.0) app token, gc_ + 22 letters/digits: groups telemetry under your private dashboard analytics?: boolean; // (0.3.0-beta.1) [false] interaction analytics for the app's dashboard: page views, engaged time, - // scroll depth, clicks, rage and dead clicks per element key; needs telemetry and the token + // scroll depth, clicks, rage and dead clicks per element key, and navigation within a page load + // (how each view was reached, the previous route, the first interaction, the exit and the path); needs telemetry and the token scope?: "app" | "functions"; // (0.3.0) ["app"]; "functions": decisions and standing questions only for activity inside protect()ed functions (rt.ask() still answered) // batch 12 (docs/runtime/OPTIONS-SPEC.md); defaults in brackets @@ -711,7 +715,7 @@ type CheckKind = "image-broken" | "image-css-broken" | "media-failed" | "stylesh | "icon-font-failed" | "csp-blocked" | "text-invisible" | "text-template-leak" | "text-placeholder" | "text-encoding" | "css-var-undefined" | "image-distorted" | "page-overflow-x" | "viewport-meta-missing" | "layout-shift" | "page-blank" | "click-blocked" | "pointer-events-leak" | "scroll-lock-stuck" | "control-unreachable" - | "focus-obscured" | "dialog-covered"; + | "focus-obscured" | "dialog-covered" | "ad-heavy" | "text-warped"; // ad-heavy, text-warped: VISUAL.md 3.24, 3.25 (report only) interface CheckFinding { id: string; // "c1", "c2", ... diff --git a/docs/runtime/VISUAL.md b/docs/runtime/VISUAL.md index baec2ef1..900d5d61 100644 --- a/docs/runtime/VISUAL.md +++ b/docs/runtime/VISUAL.md @@ -7,6 +7,8 @@ of the checks (section 19, "Review of 2026-10-13") found correct pages that look and the code changed again (measured as run 5). The open owner decisions are in [section 17](#17-owner-decisions-and-open-questions). [CONTRACT.md](CONTRACT.md) section 14, [API.md](API.md), [OPTIONS-SPEC.md](OPTIONS-SPEC.md) section 4.20 and the CHANGELOG have the matching entries. +Two checks were added on 2026-10-25 at the owner's request for "strange sites": `ad-heavy` (section 3.24) and +`text-warped` (section 3.25), with their implementation notes at the end of section 19. The owner asked on 2026-10-13 for GenClass to find visual and CSS bugs that never throw an error (white text on a white background, broken images, "so much more") and to fix them. Many generated ("vibecoded") sites and company @@ -226,10 +228,12 @@ wrong but usable; **low** means a defect with a small visible effect. | 21 | `control-unreachable` | a control covered by a fixed element even at max scroll | high | no | all | | 22 | `focus-obscured` | a focused element or anchor target hidden under a sticky header | medium | no | all | | 23 | `dialog-covered` | an open dialog covered by other elements, or its buttons off-screen | high | no | all | +| 24 | `ad-heavy` | ad slots cover more than 30% of the page or half the first screen, a sticky ad over 30% of the screen, or an ad interstitial (added 2026-10-25, section 3.24) | high or medium | no | all | +| 25 | `text-warped` | text that cannot be read as text: overlapping lines, a line cut mid-way by its box, characters no font can draw, mirrored or skewed text, letter spacing that splits words, text rendered below 8 px (added 2026-10-25, section 3.25) | medium | no | all | Checks 1 to 7, 15 and 16 are mostly browser-reported and cost close to nothing (event listeners and property reads). -Checks 8 to 14 and 17 are measured layout and text checks behind the settle and persistence gates. Checks 18 to 23 -run on interaction or on a cheap periodic read. +Checks 8 to 14, 17, 24 and 25 are measured layout and text checks behind the settle and persistence gates. Checks 18 +to 23 run on interaction or on a cheap periodic read. Each entry lists: what it measures, when it reports, what it skips, the evidence fields (local), the severity, the suggested developer fix, and the runtime fix if any. @@ -705,6 +709,125 @@ The owner's first example. One check with two dimensions: the colour scheme the `max-height: 90dvh; overflow-y: auto;`. - Runtime fix: none (moving elements or changing `z-index` is risky). +### 3.24 `ad-heavy` + +Added 2026-10-25 at the owner's request ("the classifier should detect strange sites, like too many ads, warped text"). +It is a measured check like the others: geometry of elements the markup names as ad slots, against fixed shares taken +from the Coalition for Better Ads standards. It does not judge whether an ad is good, and it never feeds the model. + +- Measures (full passes only, in the pass's idle slices: one `querySelectorAll` for elements whose `id` or `class` + contains `ad` or `sponsor`, the ad attributes below and `iframe`; at most 5,000 names read, no layout read for + them, a step every 50; at most 50 regions measured): + - **ad-like regions**, each `visible` (section 2.1), at least 40 x 20 px: + - an element whose `id` or a class name, split into words at `-`, `_`, digits and lower-to-upper case changes, + contains one of the words `ad`, `ads`, `advert`, `adverts`, `advertisement`, `advertisements`, `advertising`, + `adslot`, `adunit`, `adbox`, `adsbygoogle`, `adsense`, `sponsored`, or that has one of the attributes + `data-ad-slot`, `data-ad-client`, `data-ad-unit`, `data-ad-unit-path`, `data-adunit`, `data-google-query-id` + (the ad tags Google AdSense, Google Ad Manager and Prebid write; `div-gpt-ad-…` and `google_ads_iframe_…` match + through the word `ad` and `ads`). Only names are read, never text: `header`, `shadow`, `badge`, `add-to-cart` + and `adaptive` contain no such word; + - an `iframe` from another site (a different registrable domain, as the asset checks compare hosts) whose box is + a fixed display ad size within 2 px: 300 x 250, 336 x 280, 728 x 90, 970 x 90, 970 x 250, 300 x 600, 160 x 600, + 120 x 600, 300 x 1050, 320 x 50, 320 x 100, 300 x 50, 468 x 60, 250 x 250, 200 x 200 (the IAB standard units + and the sizes Google AdSense lists). Other cross-site iframes (video players, maps, payment fields, captchas, + chat widgets) are not ad-like. + - Only the outermost region counts when regions nest (an ad container holding an ad iframe is one region). A named + element larger than half the page (an in-flow wrapper such as `
`), `html` and `body` are not + regions; their descendants still can be. + - **Shares**, each the area of the union of the regions' boxes (overlaps counted once), with the viewport width `vw` + (`clientWidth`) and height `vh`: + - `firstShare`: the regions inside the first screen (document box `0..vw` x `0..vh`; a `fixed` region at its place + on the screen) over `vw x vh`; + - `fixedShare`: the largest single `fixed` region on the screen over `vw x vh` (a `sticky` region is measured at its + place in the page, an under-count while it is stuck); + - `pageShare`: the in-flow regions over the page (`vw x scrollHeight`), combined with the fixed ones, which cover + their share of every screen: `1 - (1 - inFlow) x (1 - fixedAll)`. +- Reports when, at two measurements 2,000 ms apart and after network quiet (ads load late and rotate), the same + reason holds, in this order: + - `interstitial`: one `fixed` region covers at least **50%** of the screen and is on top at its centre + (`elementFromPoint`). Better Ads: prestitial ads and full-screen scrollovers. + - `sticky`: one `fixed` region covers at least **30%** of the screen. Better Ads: large sticky ads (desktop, "more + than 30% of the screen"). + - `density`: `pageShare` at least **30%**. Better Ads: mobile ad density higher than 30% (the standard measures the + ads' height in the main content; the share of the page's area is the same on a phone, where ads are as wide as the + content, and lower on a desktop with side rails). + - `first-screen`: `firstShare` at least **50%**: more than half of what a visitor first sees is ads. +- Skips: regions in `[data-genclass-ignore]`, `aria-hidden` or `visual.ignore` (an app can list its own house + promos); empty slots an ad blocker collapsed (they are not `visible`); pinch zoom (abstain list); the light tier. + Same-site iframes are never ad-like by size alone. +- Evidence: `reason`, `count` (regions), `firstShare`, `pageShare`, `fixedShare` (fractions, two decimals), the + largest region as the finding's element with every region folded into it. +- Severity: high for `interstitial` (the page cannot be used until it is closed), medium otherwise. +- Suggested fix (text, not CSS): fewer or smaller ad units; keep ads under 30% of the page on phones, no sticky unit + over 30% of the screen, no full-screen interstitial before the content; reserve the slots' space so they do not + shift the page. +- Runtime fix: none (hiding an ad changes what the site's owner sells). + +### 3.25 `text-warped` + +Added 2026-10-25 with `ad-heavy`. Text that is on the page and visible but that a reader cannot read as text. Every +reason is a measurement of boxes (`Range.getClientRects()` line boxes, `getBoundingClientRect()`, computed +`transform`, `font-size`, `letter-spacing`, `word-spacing`) or of code points; nothing reads what the text says. + +- Measures, on the `text-invisible` candidates (elements with a direct text node in the viewport, at most 200 per pass, + one element per step), after `skipText` (section 3.8: disabled controls, SVG text, single icon glyphs, emoji, + `[aria-busy]`) and the walk exclusions of section 3.9 (`code`, `pre`, inputs and editable regions): + - `overlap`: two line boxes of the element's first text node overlap vertically by at least **50%** of the smaller + one's height and horizontally by at least 8 px (lines on the same row never overlap horizontally; normal + `line-height` from 1.0 leaves at most about 25% overlap of the font's content area). Not when the element or one of + its 3 nearest ancestors sets `text-overflow: ellipsis` (Chromium gives a truncated line two boxes, the whole run and + the part shown, one inside the other). Also between the first lines + of two different candidates, when at the centre of the overlap `elementsFromPoint` holds both, nothing between + them paints a background, an image or a replaced element (the upper text does not hide the lower one), neither is + `fixed` or `sticky` (a sticky header over scrolling text is normal), neither contains the other, both have opaque + colour and no partial opacity, both have at least 3 letters, and their font sizes are within a factor of 2 + (decorative backdrop lettering is larger and usually translucent). + - `clipped`: the nearest ancestor-or-self with `overflow` `hidden` or `clip` (at most 3 levels up, at least 20 px + wide) cuts the first line mid-way: horizontally, the line runs at least 4 px past the box's right edge (left edge + in right-to-left text) while at least 4 px of it are inside; vertically, only the first line's visible part is + between **20% and 80%** of its height (a control whose text is cut through the middle). Not when, from the element + up to the box, something sets `text-overflow: ellipsis`, `-webkit-line-clamp` or a `transform` (a carousel track, + a marquee); when, from the element up to 3 levels above the box, something has a `mask-image` (a "read more" + fade); when the element is inside a container that scrolls on its own; or when the element or the box is + animating. + - `tofu`: the text node holds a lone UTF-16 surrogate (a string cut through an emoji) or a code point the browser's + Unicode tables call unassigned (`\p{Cn}`, which includes the noncharacters), and its line box is drawn (width + above 0). No font has a glyph for these, so they render as boxes. U+FFFD is `text-encoding` (section 3.11), not + counted twice. Whether a font has a glyph for an assigned character is not readable from the DOM and is not + measured (a canvas probe would compare widths, and monospace fonts make that a guess). + - `mirrored` and `skewed`: the product of the computed `transform`, `scale` and `rotate` of the element and its + ancestors (2 x 2 part; a `matrix3d` is projected) has a negative determinant (the text reads backwards; not when + an ancestor sets `backface-visibility: hidden`, the back face of a flip card; and only for text with at least 3 + letters, so a flipped arrow or icon glyph is not text), or its two axes are more than + **20°** away from a right angle (design slants are 8 to 15°), or one axis is stretched to at least **2x** the + other. Pure rotation is never reported (vertical axis labels and rotated badges read fine). For a rotated or skewed + element the line boxes are bounding boxes, not the text's own, so `overlap`, `clipped` and `tiny` are measured + only when the transform product has no rotation or skew. + - `spacing`: computed `letter-spacing` at least **0.5 em** with at least two words of two or more letters (the gap + between letters is then wider than a space, about 0.25 to 0.33 em, so words no longer stand apart), or at most + **-0.2 em** with at least 3 letters (letters run into each other; tight display headings use -0.02 to -0.05 em), + or `word-spacing` at most -0.2 em with at least two words (the space between words is gone). + - `tiny`: `font-size` times the scale of that transform product (the square root of the determinant) is below + **8 px** and the drawn line box is under 10 px tall, for text still meant to be read: at least 3 letters, opaque + colour, no partial opacity, not `pointer-events: none` (miniature previews of a page set it), not inert or + `aria-hidden`. +- Reports when: the same reason holds at two measurements 1,000 ms apart and the element is not animating. One + finding per element, for the first reason that holds in the order `tofu`, `mirrored`, `skewed`, `spacing`, `tiny`, + `overlap`, `clipped`; the overlap between two elements is tested only for elements without a finding, at most 50 + overlapping pairs per pass. +- Skips: translated pages (abstain list), `rt` and `rp` (ruby annotations sit over their base), elements in + `[data-genclass-ignore]` or `visual.ignore`, the light tier. +- Evidence: `reason`, and per reason: the overlap fraction and the line heights, `line-height` and `font-size`; the cut + px and the clipping box's selector; the offending code points (`U+D83D`); the skew angle, determinant and stretch and + the selector of the element whose transform does it; the spacing in em; the rendered px and the scale. Text is in the + finding's `target` only, as for every check (local). +- Severity: medium. +- Suggested fix, by reason: `line-height: 1.4;`; `text-overflow: ellipsis;` on the clipping box, or `height: auto;` + for a control; slice strings by code point (`Array.from(s).slice(0, n)` or `Intl.Segmenter`), never by UTF-16 index; + `transform: none;` on the element that mirrors or skews; `letter-spacing: 0.1em;` or `word-spacing: normal;`; + `font-size: 12px;` or remove the scale. +- Runtime fix: none. + ## 4. Held back from v1 until measured These were researched and are useful, but their expected precision is medium, or they judge design rather than @@ -713,7 +836,7 @@ breakage. Each needs bench numbers (section 15) before it ships, and some belong | candidate | why it waits | |---|---| | content stuck at `opacity: 0` (a scroll-reveal animation that never ran) | expected medium-high; cross-fade slideshows, tabs and hover reveals look the same. Report-only first | -| clipped text in a control (`scrollWidth > clientWidth + 2`, overflow hidden, no ellipsis) | expected medium-high; icons and ripples also enlarge `scrollWidth` | +| clipped text in a control (`scrollWidth > clientWidth + 2`, overflow hidden, no ellipsis) | expected medium-high; icons and ripples also enlarge `scrollWidth`. The line-box form (a line cut by its box, measured with `Range.getClientRects()`, which icons and ripples do not enlarge) shipped on 2026-10-25 as `text-warped` reason `clipped` (section 3.25) | | raw markdown in rendered text (`**bold**`, `[text](https://...)`) | chat UIs show typed markdown on purpose | | lazy image that never loads | needs a reliable network-quiet signal per image | | text over images and gradients | the runtime cannot read pixels; gradient stops are feasible, sampled images need same-origin canvas reads or a scanner | @@ -916,6 +1039,8 @@ Budgets: | overflow culprit | 3,000 elements | only when the page fact holds | | `control-unreachable` | 50 controls | 250 hit tests at most, once per max-scroll event | | `css-var-undefined` | 5,000 rules | once per stylesheet set, in steps of 200 rules and one reference per step; a cross-origin sheet stops it before any walk | +| `ad-heavy` | 5,000 names, 50 regions | full passes only; one `querySelectorAll` for names and iframes, names read in steps of 50 without layout, the union of at most 50 boxes | +| `text-warped` | the 200 `text-invisible` candidates | one element per step (its line boxes, its transform chain up to 30 ancestors); the pairwise overlap test compares first-line boxes and hit-tests only pairs that overlap | | event handlers | no page reads | an event records what it needs (the target, the point); the read runs in the next idle callback | Back-off: if one step takes more than 16 ms (a forced layout on a huge DOM), the pass ends, the settle wait doubles @@ -940,7 +1065,7 @@ type CheckKind = | "icon-font-failed" | "csp-blocked" | "text-invisible" | "text-template-leak" | "text-placeholder" | "text-encoding" | "css-var-undefined" | "image-distorted" | "page-overflow-x" | "viewport-meta-missing" | "layout-shift" | "page-blank" | "click-blocked" | "pointer-events-leak" | "scroll-lock-stuck" - | "control-unreachable" | "focus-obscured" | "dialog-covered"; + | "control-unreachable" | "focus-obscured" | "dialog-covered" | "ad-heavy" | "text-warped"; interface CheckFinding { /** "c1", "c2", ... per runtime (a counter). */ @@ -1461,3 +1586,19 @@ finds it). Run 5 of the bench measured the result (RESULTS.md section 14). - **Defaults and telemetry.** The `"local"` default used telemetry's `isLocalHost`, which also covers Capacitor, Tauri, Electron and intranet hosts; it now uses the narrow `devHost` (7.2). The stub no longer loads the chunk in a hidden tab or a prerendered page. The `visual` telemetry fields were removed (section 10). + +### `ad-heavy` and `text-warped` (2026-10-25, branch `strange-sites`) + +What the build does where sections 3.24 and 3.25 were not specific, and what the first measurements changed: + +- **Where they run.** `ad-heavy` in full passes only (it is a page-level share; dirty-subtree passes skip it), after + `css-var-undefined`; `text-warped` after `text-invisible` on the same candidates (the text walk now collects them when + either check is on). Both are full-tier kinds (not in the light tier), `text-warped` abstains on translated pages and + `ad-heavy` under pinch zoom. Code: `src/visual/checks/ads.ts` and `src/visual/checks/warped.ts`, in the lazy chunk. +- **The ellipsis case.** The first browser run reported `overlap` on two correct elements with `text-overflow: + ellipsis`: Chromium returns two line boxes for a truncated line (the whole run and the part shown, the second inside + the first), which also describes three lines at `line-height: 0`. Self-overlap is now skipped under an ellipsis + (section 3.25); a `line-height: 0` paragraph is still found. +- **Recall and false reports** are in RESULTS.md section 21 (the Chromium fixtures, and the demos and templates bench + run on a development machine). The compat apps and the public sites were not run for these two kinds yet: they + need the bench workflow (section 15), which loads public sites and is the owner's to start. diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 4f9b19d1..020256c6 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- **Two measured visual checks: `ad-heavy` and `text-warped`** ([docs/runtime/VISUAL.md](../../docs/runtime/VISUAL.md) + sections 3.24 and 3.25). `ad-heavy` reports a page whose ad slots (elements whose id or class names an ad slot, the + AdSense, Ad Manager and Prebid attributes, cross-site frames at a fixed ad size) cover 30% of the page, half the first + screen, a fixed ad 30% of the screen (the Coalition for Better Ads thresholds), or a full-screen ad on top of the page + (an interstitial, high). `text-warped` reports text a reader cannot read as text, from line boxes, rects, transforms + and code points: two lines overlapping by half a line, a line cut mid-way by its overflow box, lone surrogates and + unassigned code points (half an emoji), mirrored text, text skewed over 20° or stretched 2x, letter spacing of + 0.5 em or more (or -0.2 em and less), and text drawn below 8 px that is still meant to be read. Both are kind + `"check"`, report only (no runtime fix), run in the lazy chunk's idle slices on the full tier, and never feed the + model; `CheckKind` gains both. First load unchanged (0 B of first-load code; the lazy visual chunk grows by about + 6.5 KB gzip). +- **Interaction analytics: navigation paths** ([docs/runtime/ANALYTICS.md](../../docs/runtime/ANALYTICS.md) section + 11, TELEMETRY.md "Interaction analytics"). With `analytics: true` (still off by default, still only with the app's + token), `interactions` events also say how each page view was reached (`nav`: load, reload, back_forward, push, back, + forward, pop), the previous view's route in the same page load and the ms between (`from`, `fromMs`), the element key + of the view's first click or keyboard focus on a form field (`first`, `firstMs`), and at pagehide that the page load + ended there with its routes in order (`exit`, `path`, at most 10). Never text, the referrer or anything that links + two page loads. The console notice now says "page views and the path between them". The dashboard's Visitors view + shows the most taken paths, page-to-page steps, entries, exits and back/forward use, and the first interaction per + page. - **`dashboard` prints the link to this app's free dashboard.** `npx @genclass/runtime dashboard` prints the link to the dashboard of the app in the current folder: the private link in `.genclass.local` (nothing is contacted), else the app's page in the signed-in account (one `GET /api/me`), else the account's apps; `--open` also opens it in diff --git a/packages/runtime/README.md b/packages/runtime/README.md index e09985c5..1c4886fa 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -442,7 +442,9 @@ console warning and is ignored. **What it shows:** decisions, detections by diagnosis, actions taken, top routes and protected functions, model load, backend (WebGPU or WASM) and latency, runtime and model versions, and recent detections. With `analytics: true` (new in `0.3.0-beta.1`, off by default), also how visitors use the app: page views, time on page and -scroll depth per page, the most clicked elements, and rage and dead clicks ([Privacy and telemetry](#privacy-and-telemetry)). +scroll depth per page, the most clicked elements, rage and dead clicks, and how visitors move: the most taken paths, +page-to-page steps with the time before each, entries, exits, back and forward use and the first element used per page +([Privacy and telemetry](#privacy-and-telemetry)). Put `data-genclass-name="pricing-cta"` on an element to give it a readable name there. **What it needs:** telemetry on (the default with `GenClass.init()` in a browser) on a deployed host. Local hosts @@ -725,6 +727,8 @@ are skipped, not guessed. | `page-blank` | the main region shows no text and no media after the page settled | | `click-blocked`, `pointer-events-leak`, `scroll-lock-stuck` | an invisible layer that took two clicks; `pointer-events: none` or a scroll lock left on `body` | | `control-unreachable`, `focus-obscured`, `dialog-covered` | a control under a fixed bar with no controls of its own; a focused element or anchor target under a sticky header or banner that paints over it; a dialog under other elements | +| `ad-heavy` | ad slots (named so by their id, class or ad attributes, or cross-site frames at ad sizes) cover 30% of the page or half the first screen, a fixed ad 30% of the screen, or a full-screen ad sits on top of the page | +| `text-warped` | text that cannot be read as text: lines on top of each other, a line cut through by its box, half an emoji or another character no font can draw, mirrored or skewed text, letters spaced wider than words, text drawn below 8 px | **Where they run.** On by default on development hosts (a dev server's port on `localhost`, `127.0.0.1`, `[::1]` or a `*.localhost` name, and `*.test` names) and when the devtools overlay mounts, where the developer reads the console; @@ -1248,7 +1252,7 @@ GenClass.init({ model: { baseUrl: "/genclass-model/", ortWasmPaths: "/genclass-m - **Anonymous diagnostics are on by default** with `GenClass.init()` in a browser, except on local and private hosts (`localhost`, `*.localhost`, `*.local`, `*.test`, `127.0.0.1`, `::1`, private IP ranges) (`0.2.1` sends from local hosts too, by mistake; fixed in `0.2.2`, see the CHANGELOG), in Node/SSR, and in `createRuntime()`. They go to the GenClass maintainers' collector ([`telemetry-worker/`](https://github.com/genclass-dev/GenClass-lib/tree/main/telemetry-worker), a Cloudflare Worker writing to a private R2 bucket) and are used to improve the model. - **By default no text from your page is sent.** Sent: a random per-page session id, versions, mode, device class, the app's hostname and id-normalised path (from `0.3.0` a segment with an `@`, such as an e-mail address, is `:id` too), and per decision (every detection and every decision whose diagnosis is not `expected`, every action and veto; of the routine ones, one in four and at most 200 per page load; counts are exact; `0.2.2` and earlier send every decision) the trigger, the diagnosis and its confidence, the action and gate, the outcome and latency. The collector stores no IP address or user agent. One console notice per page says it is on. - **The measured visual checks send nothing** (new in `0.3.0`): their findings stay in the page (the console, `rt.checks()`, your own sinks). -- **Interaction analytics are off unless you turn them on** (new in `0.3.0-beta.1`): `GenClass.init({ token, analytics: true })` also sends, per page view, the route, engaged time, scroll depth and clicks, rage clicks and dead clicks per element, for your dashboard. Elements are named from markup (`data-genclass-name`, `data-testid`, an id, a link's target, class names, a short path), never their text; nothing is stored on the device and no visitor id exists. They need the token and telemetry on, every opt-out above turns them off, and they never feed a decision or the model. Where analytics need consent (the EU, EEA and UK), leave them off and call `runtime.analytics.start()` after consent. Details: [TELEMETRY.md](https://github.com/genclass-dev/GenClass-lib/blob/main/packages/runtime/TELEMETRY.md#interaction-analytics). +- **Interaction analytics are off unless you turn them on** (new in `0.3.0-beta.1`): `GenClass.init({ token, analytics: true })` also sends, per page view, the route, engaged time, scroll depth and clicks, rage clicks and dead clicks per element, and within one page load how each view was reached, the route before it with the time between, the first element used on it, and where the page load ended with its routes in order (at most 10), for your dashboard. Elements are named from markup (`data-genclass-name`, `data-testid`, an id, a link's target, class names, a short path), never their text; nothing is stored on the device and no visitor id exists. They need the token and telemetry on, every opt-out above turns them off, and they never feed a decision or the model. Where analytics need consent (the EU, EEA and UK), leave them off and call `runtime.analytics.start()` after consent. Details: [TELEMETRY.md](https://github.com/genclass-dev/GenClass-lib/blob/main/packages/runtime/TELEMETRY.md#interaction-analytics). - **The situation text the model read is sent only if you opt in** with `GenClass.init({ telemetry: { include: { situation: true } } })`. It is redacted by field meaning, but can still contain route names with query strings, short error messages and short field values; read [TELEMETRY.md](https://github.com/genclass-dev/GenClass-lib/blob/main/packages/runtime/TELEMETRY.md) before enabling it. - **Opt out:** `GenClass.init({ telemetry: false })`, `?genclass=no-telemetry`, `localStorage.setItem("genclass.telemetry", "off")`; browsers sending Global Privacy Control are never collected. - **With a token** (`0.3.0`: `token` option, `data-token`, `token=` in the meta tag), each batch also carries diff --git a/packages/runtime/TELEMETRY.md b/packages/runtime/TELEMETRY.md index bd4b5c20..c4d9be32 100644 --- a/packages/runtime/TELEMETRY.md +++ b/packages/runtime/TELEMETRY.md @@ -101,7 +101,7 @@ Every event has `t` (type), `seq` (0, 1, 2, … per page) and `at` (ms since sta | `breaker` | the breaker tripped or reset | `tripped`, `reason`, `undos`, `errors`, `decisions` (ids) | | `model-error` | model errors (first 20 per page; all are counted) | `code`, `message` (≤ 200 chars, URLs replaced by ``: a model host's message can quote the browser's worker error, which names the script and the page origin) | | `summary` | about once a minute (≥ 60 s apart) while decisions continue, never capped; when the tab is hidden, on pagehide and on destroy | `reason`, `counts` (decisions, acted, detections (likely ones), `possibleDetections` (0.3.0: the possible ones, counted apart), actions applied/failed, late reverts, undos, vetoes, breaker trips, per trigger / diagnosis / action ran, detections per diagnosis, action limits hit, model errors per code, fail-opens per reason and trigger), `runtime` (model p50/p95 latency, decisions, dropped, hidden-tab skips, held time, denied and shadow counts, error count), `telemetry` (queued, dropped, send failures, events sent, `decisionRecords` (decisions whose records were sent), `decisionsSampledOut`, `decisionsCapped`) | -| `interactions` | only with `analytics: true` and a token (or a custom endpoint): when a page view ends (a route change, page exit), when the tab is hidden, and before a `summary`, if anything changed ([Interaction analytics](#interaction-analytics)) | `view` (page view number in this page load), `route` (normalised as on `session`), `start` (the view's first event: a page view), `engagedMs`, `scroll` (milestones 25 / 50 / 75 / 100 first reached since the previous event), `clicks`, `rage`, `dead` (counts per element key, at most 50 / 20 / 20 keys, the rest under `(other)`), `end` (the view ended). Counts are deltas since the view's previous event | +| `interactions` | only with `analytics: true` and a token (or a custom endpoint): when a page view ends (a route change, page exit), when the tab is hidden, and before a `summary`, if anything changed ([Interaction analytics](#interaction-analytics)) | `view` (page view number in this page load), `route` (normalised as on `session`), `start` (the view's first event: a page view), `engagedMs`, `scroll` (milestones 25 / 50 / 75 / 100 first reached since the previous event), `clicks`, `rage`, `dead` (counts per element key, at most 50 / 20 / 20 keys, the rest under `(other)`), `end` (the view ended). Counts are deltas since the view's previous event. Navigation paths (new on `strange-sites`, after `0.3.0-beta.1`): on the view's first event `nav` (how it was reached: `load`, `reload`, `back_forward`, `push`, `back`, `forward`, `pop`) and, from a page load's second view on, `from` (the previous view's route) and `fromMs`; once per view `first` (the element key of the first click, or of the first focus on a form field after a key press) and `firstMs`; on the page load's last event (pagehide) `exit: true` and `path` (its routes in order, at most 10, then `"(more)"`) | Not sent: `expectation` reports (a learned step that did not happen, `docs/runtime/EXPECTATIONS.md`) and anything else learned about the page (flows, profiles, relations); those stay in the browser (`localStorage`, `rt.learned()`). @@ -183,7 +183,7 @@ without either, `analytics: true` warns once in the console and records nothing. Privacy Control and the local-host default turn them off too. When they start, the console says so once: ``` -[GenClass] Interaction analytics are on (this app turned them on): page views, time on page, scroll depth and clicks per element, never page text. +[GenClass] Interaction analytics are on (this app turned them on): page views and the path between them, time on page, scroll depth and clicks per element, never page text. ``` What is recorded, per page view (a page load, and each route change to a new path): @@ -201,12 +201,20 @@ What is recorded, per page view (a page load, and each route change to a new pat `[data-testid="…"]`, `tag#id` (ids that look generated or like data are skipped), a link's target (`a[href="/route"]` on your site, the other site's origin only), the tag with up to two stable class names, or an `nth-of-type` path of up to 4 levels under a named ancestor or a landmark. At most 120 characters. Any attribute value that looks like data - (an e-mail address, a URL, 4 or more digits, 12 or more hex digits) is not used. + (an e-mail address, a URL, 4 or more digits, 12 or more hex digits) is not used; +- **navigation within the page load** (added after `0.3.0-beta.1`, [design](../../docs/runtime/ANALYTICS.md#11-navigation-paths-2026-10-25)): + how each view was reached (a page load, a reload, the browser's back or forward button, an in-app route change, an + in-app back or forward), the previous view's route and the time from its start to this view's, the element key of + the view's first interaction (a click, or a focus on a form field right after a key press) and the time to it, and + when the page load ends (`pagehide`), the routes of its views in order (at most 10) and that it ended there. A full + page navigation to another page of the same site starts a new page load, which is not linked to the previous one, + so on a site without client-side routing each path is one page long. **Never recorded:** element text, labels, `alt`, `title`, placeholders, input values or what was typed, mouse coordinates, the order or timing of clicks, the query string or fragment, the referrer, the page title, the screen -size, a visitor id. Nothing is written to cookies or `localStorage`, so page loads by the same person cannot be linked -and the dashboard does not count unique visitors. Synthetic events (`isTrusted` false) and events inside +size, the history entries' keys or ids, a visitor id. The order of page views and the time between them are recorded +only within one page load. Nothing is written to cookies or `localStorage`, so page loads by the same person cannot be +linked and the dashboard does not count unique visitors. Synthetic events (`isTrusted` false) and events inside `[data-genclass-ignore]` are not counted. They never reach the model or a decision: they are recorded by their own listeners (a separate file the runtime loads diff --git a/packages/runtime/src/analytics/index.ts b/packages/runtime/src/analytics/index.ts index 4ad76311..31762ee3 100644 --- a/packages/runtime/src/analytics/index.ts +++ b/packages/runtime/src/analytics/index.ts @@ -9,6 +9,10 @@ // - Counts per page view, sent as `interactions` events whose counts are deltas since the view's previous event: // page views, engaged time, scroll milestones, and clicks, rage clicks and dead clicks per element key. Never element // text, input values, coordinates, the order or timing of clicks, the query string or a visitor id. +// - Navigation paths (ANALYTICS.md section 11): on a view's first event, how it was reached (`nav`) and the previous +// view of this page load with the ms between (`from`, `fromMs`); once per view, the element key of the first +// interaction (`first`, `firstMs`); on the page load's last event (pagehide), `exit` and the ordered `path` of its +// routes (at most PATH_MAX). Never the referrer, history entries' keys or anything that links two page loads. // - Element keys come from markup only (keyOf); every part read from an attribute goes through telemetry's DATA_LIKE. // - Only trusted events count (isTrusted, whatever observe.untrustedEvents says), never those inside // [data-genclass-ignore] (the devtools overlay). @@ -34,6 +38,10 @@ export const DEAD_MS = 1000; export const MAX_CLICK_KEYS = 50; export const MAX_FLAG_KEYS = 20; export const KEY_MAX = 120; +/** Routes in a page load's `path`; a longer path ends with "(more)". */ +export const PATH_MAX = 10; +/** How a view was reached (ANALYTICS.md section 11.1). */ +export type NavType = "load" | "reload" | "back_forward" | "push" | "back" | "forward" | "pop"; /** Scroll position reads at most this often; other inputs update the engagement anchor at most this often. */ const SCROLL_MS = 250; const INPUT_MS = 500; @@ -43,6 +51,10 @@ const INTERACTIVE = "button,a,input,select,textarea,summary,label,[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=option],[role=switch],[contenteditable=''],[contenteditable=true]"; /** Links and buttons: the elements a dead click is counted on. */ const DEAD = "a,button,[role=button],[role=link],[role=tab],[role=menuitem],input[type=button],input[type=submit],input[type=reset]"; +/** Form fields: a keyboard user's first interaction is often a focus on one. */ +const FIELD = "input,select,textarea,[contenteditable=''],[contenteditable=true]"; +/** A focus counts as an interaction when a key was pressed this recently (Tab), not when the page moved focus. */ +const FOCUS_KEY_MS = 1000; const LANDMARKS = new Set(["header", "nav", "main", "aside", "footer", "form", "dialog"]); // From src/visual/measure.ts (the visual chunk; a static import would make this chunk depend on another file). const GENERATED_ID = /^[:«]|[:»]$|^(headlessui|radix|react-aria|mui|chakra|ember|ext-gen|yui_|j_id|rc_|__next)|^[a-f0-9-]{12,}$|\d{4,}/i; @@ -202,7 +214,23 @@ export interface AnalyticsChunk { } let noticeShown = false; -export const ANALYTICS_NOTICE = "[GenClass] Interaction analytics are on (this app turned them on): page views, time on page, scroll depth and clicks per element, never page text."; +export const ANALYTICS_NOTICE = "[GenClass] Interaction analytics are on (this app turned them on): page views and the path between them, time on page, scroll depth and clicks per element, never page text."; + +/** Navigation Timing's type for the page load's first view: "load", "reload" or "back_forward". */ +export function loadNav(g: { performance?: { getEntriesByType?: (t: string) => { type?: string }[] } }): NavType { + try { + const t = g.performance?.getEntriesByType?.("navigation")?.[0]?.type; + return t === "reload" ? "reload" : t === "back_forward" ? "back_forward" : "load"; + } catch { + return "load"; + } +} + +/** The direction of a history traversal from the Navigation API's entry indexes ("pop" when either is unknown). */ +export function popNav(before: number | undefined, after: number | undefined): NavType { + if (typeof before !== "number" || typeof after !== "number" || before === after) return "pop"; + return after < before ? "back" : "forward"; +} /** Start recording (the stub checked telemetry and the token). `ignored`: dom-user's ignoredEvent; `data`: DATA_LIKE. */ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: Event) => boolean, data: RegExp): AnalyticsController { @@ -241,6 +269,28 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: let pid = 0; let mo: MutationObserver | null = null; const timers = new Set(); + // navigation: how the open view was reached, the previous view of this page load, the first interaction, the path + let nav: NavType = loadNav(g as Parameters[0]); + let pendingNav: NavType | null = null; + let from: string | undefined; + let fromMs = 0; + let opened = 0; + let prevRoute: string | undefined; + let prevOpened = 0; + let firstKey = ""; + let firstMs = 0; + let firstSent = false; + let lastKey = -Infinity; + let path: string[] = []; + const entryIndex = (): number | undefined => { + try { + const i = (g.navigation as { currentEntry?: { index?: number } } | undefined)?.currentEntry?.index; + return typeof i === "number" && i >= 0 ? i : undefined; + } catch { + return undefined; + } + }; + let navIndex = entryIndex(); const here = () => t.route(); const account = (at: number) => { @@ -251,6 +301,19 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: view = ++views; route = here(); start = true; + // the first view of a page load keeps its load type; later ones were reached by a traversal or a route change + if (pendingNav) nav = pendingNav; + else if (prevRoute !== undefined) nav = "push"; + pendingNav = null; + from = prevRoute; + fromMs = prevRoute !== undefined ? Math.max(0, Math.round(at - prevOpened)) : 0; + prevRoute = route; + prevOpened = opened = at; + navIndex = entryIndex(); + firstKey = ""; + firstSent = false; + if (path.length < PATH_MAX) path.push(route ?? "(unknown)"); + else if (path.length === PATH_MAX) path.push("(more)"); engaged = 0; acc = at; last = visible ? at : -Infinity; @@ -278,14 +341,27 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: } }; - /** Queue this view's `interactions` event if anything changed; `end` closes the view. */ - const emit = (end: boolean, measure = true) => { + /** Queue this view's `interactions` event if anything changed; `end` closes the view, `exit` the page load. */ + const emit = (end: boolean, measure = true, exit = false) => { if (!view) return; account(clock.now()); if (measure) read(); const e: Record = { t: "interactions", view, route }; let any = start; - if (start) e.start = true; + if (start) { + e.start = true; + e.nav = nav; + if (from !== undefined) { + e.from = from; + e.fromMs = fromMs; + } + } + if (firstKey && !firstSent) { + e.first = firstKey; + e.firstMs = firstMs; + firstSent = true; + any = true; + } const ms = Math.round(engaged); if (ms > 0) { e.engagedMs = ms; @@ -312,6 +388,14 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: e.end = true; view = 0; } + if (exit) { + // where this page load ended, and the routes it went through; a restore from the back/forward cache starts anew + e.exit = true; + e.path = path; + path = []; + prevRoute = undefined; + any = true; + } if (any) t.push(e); }; @@ -379,6 +463,33 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: timers.add(tm); }; + /** The view's first interaction: a click, or a focus on a form field right after a key press. */ + const firstAt = (key: string, at: number) => { + if (firstKey || !view) return; + firstKey = key; + firstMs = Math.max(0, Math.round(at - opened)); + }; + const onKey = (e: Event) => { + if (e.isTrusted !== false) lastKey = clock.now(); + onInput(e); + }; + const onFocus = (e: Event) => { + const at = clock.now(); + if (firstKey || at - lastKey > FOCUS_KEY_MS || !trusted(e)) return; + const el = targetOf(e); + const field = el?.closest?.(FIELD); + if (!field) return; + touch(at); + firstAt(keyOf(field, k), at); + }; + const onPop = () => { + const dir = popNav(navIndex, entryIndex()); + navIndex = entryIndex(); + if (here() !== route) pendingNav = dir; + // the nav observer's listener ran first and already opened this view as a route change: it was a traversal + else if (view && start && nav === "push" && clock.now() - opened < 50) nav = dir; + }; + const onClick = (ev: Event) => { if (!trusted(ev)) return; const at = clock.now(); @@ -386,6 +497,7 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: const el = targetOf(ev); const key = keyOf(el, k); bump(clicks, key, MAX_CLICK_KEYS); + firstAt(key, at); // rage: RAGE_CLICKS on one element within RAGE_MS, once per burst; not while the visitor selects text if (key !== rk) { rk = key; @@ -430,8 +542,13 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: const onPageHide = () => { gone = true; }; - const onPageShow = () => { + const onPageShow = (e: Event) => { gone = false; + // restored from the back/forward cache: the next view starts a new path, reached by back or forward + if ((e as PageTransitionEvent).persisted) { + pendingNav = "back_forward"; + prevRoute = undefined; + } }; const onNav = (e: RtEvent) => { if (e.kind === "nav" && view && here() !== route) { @@ -445,7 +562,10 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: [g as unknown as EventTarget, "click", onClick, opts], [g as unknown as EventTarget, "pointerdown", onInput, opts], [g as unknown as EventTarget, "pointermove", onInput, opts], - [g as unknown as EventTarget, "keydown", onInput, opts], + [g as unknown as EventTarget, "keydown", onKey, opts], + [g as unknown as EventTarget, "focusin", onFocus, opts], + // capture: at the window it runs before the nav observer's own popstate listener + [g as unknown as EventTarget, "popstate", onPop, { capture: true }], [g as unknown as EventTarget, "wheel", onInput, opts], [g as unknown as EventTarget, "touchstart", onInput, opts], // the document's own scroll only (a scrolling container's events do not reach the window in the bubble phase) @@ -462,7 +582,7 @@ export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: /* analytics never break the runtime */ } }); - t.pre = (reason) => emit(reason === "pagehide" || reason === "destroy"); + t.pre = (reason) => emit(reason === "pagehide" || reason === "destroy", true, reason === "pagehide"); // the first page view: now, or when a prerendered page is shown const first = () => open(clock.now()); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 25fe25d1..6a4e09d4 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -901,7 +901,10 @@ export interface InitOptions { * per page view, the page route, page views, engaged time (the tab visible, at most 30 s after the last input), * scroll depth (25 / 50 / 75 / 100 %) and clicks, rage clicks and dead clicks per element, the element named from * markup only (`data-genclass-name`, `data-testid`, an id, a link's target, stable class names or a short - * structural path; never its text). Sent as `interactions` telemetry events, never fed to a decision or the model. + * structural path; never its text), and navigation within one page load: how each view was reached (a load, a + * reload, back/forward, an in-app route change), the previous view's route and the ms between, the element key of + * the view's first interaction, and at pagehide the exit and the routes in order (at most 10). Page loads are never + * linked. Sent as `interactions` telemetry events, never fed to a decision or the model. * **Default: off.** Needs telemetry on and the app's `token` (or your own `telemetry.endpoint`); every telemetry * opt-out and Global Privacy Control turn it off too. Nothing is stored on the device. Where analytics need consent * (the EU, EEA and UK), leave it off and call `runtime.analytics.start()` once the visitor agrees. Also @@ -1085,7 +1088,12 @@ export interface Learned { // --------------------------------------------------------------------------- measured visual checks (VISUAL.md) -/** The v1 measured visual checks (docs/runtime/VISUAL.md section 3). */ +/** + * The measured visual checks (docs/runtime/VISUAL.md section 3). `ad-heavy` (3.24): ad slots cover 30% of the page, + * half the first screen, a sticky ad 30% of the screen, or an ad interstitial half of it. `text-warped` (3.25): text a + * reader cannot read as text (overlapping lines, a line cut mid-way, characters no font can draw, mirrored or skewed + * text, letter spacing that splits words, text drawn below 8 px). Both report only, never fix, never feed the model. + */ export type CheckKind = | "image-broken" | "image-css-broken" @@ -1109,7 +1117,9 @@ export type CheckKind = | "scroll-lock-stuck" | "control-unreachable" | "focus-obscured" - | "dialog-covered"; + | "dialog-covered" + | "ad-heavy" + | "text-warped"; /** The checks that may apply a runtime fix (opt-in, reversible, never in observe). */ export type VisualFixKind = "text-invisible" | "pointer-events-leak" | "scroll-lock-stuck"; diff --git a/packages/runtime/src/visual/checks/ads.ts b/packages/runtime/src/visual/checks/ads.ts new file mode 100644 index 00000000..feeed1e1 --- /dev/null +++ b/packages/runtime/src/visual/checks/ads.ts @@ -0,0 +1,286 @@ +// ad-heavy (docs/runtime/VISUAL.md 3.24): the share of the first screen and of the page that ad slots cover, from +// the names the markup gives them and the boxes they take. adWords, adNamed, adSize, unionArea, adShares and +// adVerdict are pure and unit-tested (test/visual-strange.test.ts). +// +// Thresholds (Coalition for Better Ads, Better Ads Standards, desktop and mobile): +// - AD_PAGE_SHARE 0.3: ad density higher than 30% (mobile), here as the share of the page's area. +// - AD_STICKY_SHARE 0.3: a sticky ad over 30% of the screen (large sticky ads). +// - AD_INTERSTITIAL_SHARE 0.5: a fixed ad over half the screen and on top (prestitials, full-screen scrollovers). +// - AD_FIRST_SHARE 0.5: more than half the first screen is ads. +// - AD_SIZE_PX 2: a cross-site iframe within 2 px of a fixed display ad size is ad-like. +// Reads only: names (id, class, a few ad attributes) without layout, then rects and one hit test per fixed region. + +import type { Ctx, Draft } from "../draft.js"; +import { named } from "../draft.js"; +import { sameSite, type Reader } from "../measure.js"; + +export const AD_PAGE_SHARE = 0.3; +export const AD_STICKY_SHARE = 0.3; +export const AD_INTERSTITIAL_SHARE = 0.5; +export const AD_FIRST_SHARE = 0.5; +export const AD_SIZE_PX = 2; +/** Names read per pass (no layout), and regions measured. */ +const MAX_NAMES = 5000; +/** Ad-like candidates kept for measuring (each costs a visibility read and a rect in one step). */ +const MAX_CANDS = 200; +const MAX_REGIONS = 50; +/** A region is at least this big (tracking pixels and collapsed slots are not ad space). */ +const MIN_W = 40; +const MIN_H = 20; + +/** Words in an id or class name that name an ad slot (VISUAL.md 3.24). */ +const AD_WORDS = new Set(["ad", "ads", "advert", "adverts", "advertisement", "advertisements", "advertising", "adslot", "adunit", "adbox", "adsbygoogle", "adsense", "sponsored"]); +/** Attributes the ad tags write (AdSense, Ad Manager, Prebid). */ +const AD_ATTRS = ["data-ad-slot", "data-ad-client", "data-ad-unit", "data-ad-unit-path", "data-adunit", "data-google-query-id"]; +/** The prefilter: names that may hold an ad word, the ad attributes, and iframes. */ +const AD_SEL = `[id*="ad" i], [class*="ad" i], [id*="sponsor" i], [class*="sponsor" i], ${AD_ATTRS.map((a) => `[${a}]`).join(", ")}, iframe`; +/** Fixed display ad sizes (the IAB standard units and the sizes Google AdSense lists). */ +export const AD_SIZES: [number, number][] = [ + [300, 250], + [336, 280], + [728, 90], + [970, 90], + [970, 250], + [300, 600], + [160, 600], + [120, 600], + [300, 1050], + [320, 50], + [320, 100], + [300, 50], + [468, 60], + [250, 250], + [200, 200], +]; + +/** The words of a name: split at `-`, `_`, digits, other punctuation and lower-to-upper case changes, lowercased. */ +export function adWords(name: string): string[] { + return name + .replace(/([a-z])([A-Z])/g, "$1 $2") + .split(/[^A-Za-z]+/) + .filter(Boolean) + .map((w) => w.toLowerCase()); +} + +/** An id, a class list or an attribute set names an ad slot. */ +export function adNamed(id: string, classes: string[], attrs: string[] = []): boolean { + if (attrs.some((a) => AD_ATTRS.includes(a))) return true; + for (const n of [id, ...classes]) if (n && adWords(n).some((w) => AD_WORDS.has(w))) return true; + return false; +} + +/** A box is a fixed display ad size within AD_SIZE_PX. */ +export function adSize(w: number, h: number): boolean { + return AD_SIZES.some(([aw, ah]) => Math.abs(w - aw) <= AD_SIZE_PX && Math.abs(h - ah) <= AD_SIZE_PX); +} + +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** The area of the union of boxes, each clipped to `clip` (coordinate compression; at most MAX_REGIONS boxes). */ +export function unionArea(boxes: Box[], clip: Box): number { + const bs = boxes + .map((b) => ({ left: Math.max(b.left, clip.left), top: Math.max(b.top, clip.top), right: Math.min(b.right, clip.right), bottom: Math.min(b.bottom, clip.bottom) })) + .filter((b) => b.right > b.left && b.bottom > b.top); + if (!bs.length) return 0; + const xs = [...new Set(bs.flatMap((b) => [b.left, b.right]))].sort((a, b) => a - b); + let area = 0; + for (let i = 0; i + 1 < xs.length; i++) { + const x0 = xs[i]; + const x1 = xs[i + 1]; + const spans = bs + .filter((b) => b.left <= x0 && b.right >= x1) + .map((b) => [b.top, b.bottom] as [number, number]) + .sort((a, b) => a[0] - b[0]); + let covered = 0; + let s = -Infinity; + let e = -Infinity; + for (const [t, bt] of spans) { + if (t > e) { + if (e > s) covered += e - s; + s = t; + e = bt; + } else if (bt > e) e = bt; + } + if (e > s) covered += e - s; + area += covered * (x1 - x0); + } + return area; +} + +/** One ad-like region as measured: its box in document coordinates (fixed: on the screen) and whether it is fixed. */ +export interface Region { + box: Box; + fixed: boolean; + /** A fixed region is on top at its centre (elementFromPoint lands inside it). */ + onTop?: boolean; +} + +export interface AdShares { + firstShare: number; + pageShare: number; + fixedShare: number; + /** The largest fixed region that is on top, as a share of the screen. */ + topShare: number; + count: number; +} + +/** VISUAL.md 3.24: the shares of the first screen, the page and the largest fixed region. */ +export function adShares(regions: Region[], vw: number, vh: number, pageH: number): AdShares { + const screen = Math.max(1, vw * vh); + const page = Math.max(1, vw * Math.max(pageH, vh)); + const flow = regions.filter((r) => !r.fixed).map((r) => r.box); + const fixed = regions.filter((r) => r.fixed); + const view: Box = { left: 0, top: 0, right: vw, bottom: vh }; + const firstShare = unionArea([...flow, ...fixed.map((r) => r.box)], view) / screen; + const fixedAll = unionArea(fixed.map((r) => r.box), view) / screen; + const inFlow = unionArea(flow, { left: 0, top: 0, right: vw, bottom: Math.max(pageH, vh) }) / page; + const one = (r: Region) => unionArea([r.box], view) / screen; + return { + firstShare, + pageShare: 1 - (1 - inFlow) * (1 - fixedAll), + fixedShare: Math.max(0, ...fixed.map(one)), + topShare: Math.max(0, ...fixed.filter((r) => r.onTop).map(one)), + count: regions.length, + }; +} + +export type AdReason = "interstitial" | "sticky" | "density" | "first-screen"; + +/** The reason to report, in the order of VISUAL.md 3.24, or null. */ +export function adVerdict(s: AdShares): AdReason | null { + if (!s.count) return null; + if (s.topShare >= AD_INTERSTITIAL_SHARE) return "interstitial"; + if (s.fixedShare >= AD_STICKY_SHARE) return "sticky"; + if (s.pageShare >= AD_PAGE_SHARE) return "density"; + if (s.firstShare >= AD_FIRST_SHARE) return "first-screen"; + return null; +} + +/** Candidates whose names or size make them ad-like (names read without layout, in steps of 50). */ +function* adCandidates(ctx: Ctx, out: Element[]): Generator { + const r = ctx.r; + const doc = r.doc; + let list: NodeListOf; + try { + list = doc.querySelectorAll(AD_SEL); + } catch { + return; + } + const loc = ctx.win.location; + let n = 0; + for (const el of list) { + if (++n > MAX_NAMES || out.length >= MAX_CANDS) break; + if (n % 50 === 0) yield; + if (el === doc.documentElement || el === doc.body) continue; + if (el.tagName === "IFRAME") { + const src = el.getAttribute("src") ?? ""; + if (/^https?:|^\/\//i.test(src) && !sameSite(src, loc)) out.push(el); + continue; + } + const attrs = AD_ATTRS.filter((a) => el.hasAttribute(a)); + if (adNamed(el.id, [...el.classList], attrs)) out.push(el); + } +} + +/** Measure the candidates: visible regions of at least MIN_W x MIN_H, iframes only at ad sizes, the outermost kept. */ +export function measureRegions(r: Reader, cands: Element[]): { el: Element; region: Region }[] { + const doc = r.doc; + const vw = r.vw; + const pageH = Math.max(doc.documentElement.scrollHeight, r.vh); + const sy = r.win.scrollY; + const kept: { el: Element; region: Region }[] = []; + for (const el of cands) { + if (kept.length >= MAX_REGIONS) break; + if (kept.some((k) => k.el.contains(el))) continue; + if (!r.visible(el)) continue; + const rc = r.rect(el); + if (rc.width < MIN_W || rc.height < MIN_H) continue; + if (el.tagName === "IFRAME" && !adSize(rc.width, rc.height)) continue; + const pin = r.pinned(el); + const fixed = !!pin && r.cs(pin).position === "fixed"; + // an in-flow wrapper the size of the page is layout, not an ad slot + if (!fixed && rc.width * rc.height > 0.5 * vw * pageH) continue; + const box: Box = fixed ? { left: rc.left, top: rc.top, right: rc.right, bottom: rc.bottom } : { left: rc.left, top: rc.top + sy, right: rc.right, bottom: rc.bottom + sy }; + const region: Region = { box, fixed }; + if (fixed) { + const hit = r.at(Math.floor((rc.left + rc.right) / 2), Math.floor((rc.top + rc.bottom) / 2)); + region.onTop = !!hit && el.contains(hit); + } + // an outer region found later replaces the inner ones it holds + for (let i = kept.length - 1; i >= 0; i--) if (el.contains(kept[i].el)) kept.splice(i, 1); + kept.push({ el, region }); + } + return kept; +} + +const pct = (x: number): string => `${Math.round(x * 100)}%`; +const two = (x: number): number => Math.round(x * 100) / 100; + +function adDraft(ctx: Ctx, regions: { el: Element; region: Region }[], s: AdShares, reason: AdReason): Draft { + const area = (b: Box) => (b.right - b.left) * (b.bottom - b.top); + const big = regions.reduce((a, b) => (area(b.region.box) > area(a.region.box) ? b : a)); + const what = + reason === "interstitial" + ? `an ad (${named(ctx, big.el)}) covers ${pct(s.topShare)} of the screen on top of the page (threshold ${pct(AD_INTERSTITIAL_SHARE)}: an interstitial)` + : reason === "sticky" + ? `a fixed ad (${named(ctx, big.el)}) covers ${pct(s.fixedShare)} of the screen (threshold ${pct(AD_STICKY_SHARE)}, Better Ads large sticky ads)` + : reason === "density" + ? `ad slots cover ${pct(s.pageShare)} of the page (threshold ${pct(AD_PAGE_SHARE)}, Better Ads ad density)` + : `ad slots cover ${pct(s.firstShare)} of the first screen (threshold ${pct(AD_FIRST_SHARE)})`; + const threshold = + reason === "interstitial" ? AD_INTERSTITIAL_SHARE : reason === "sticky" ? AD_STICKY_SHARE : reason === "density" ? AD_PAGE_SHARE : AD_FIRST_SHARE; + return { + kind: "ad-heavy", + el: big.el, + els: regions.map((x) => x.el), + count: regions.length, + key: `ad-heavy|${reason}`, + severity: reason === "interstitial" ? "high" : "medium", + measured: { reason, count: s.count, firstShare: two(s.firstShare), pageShare: two(s.pageShare), fixedShare: two(s.fixedShare) }, + threshold: { + value: threshold, + unit: reason === "density" ? "share of the page" : "share of the screen", + source: "Coalition for Better Ads standards (ad density, large sticky ads, prestitials); ad slots named by id, class or ad attributes, or cross-site iframes at ad sizes (VISUAL.md 3.24)", + }, + text: `${what}: ${s.count} ad slot${s.count === 1 ? "" : "s"}, ${pct(s.firstShare)} of the first screen and ${pct(s.pageShare)} of the page.`, + fixText: + reason === "interstitial" + ? "do not cover the page with an ad before the content: show it inline, or after the visitor's action with a visible close button" + : "use fewer or smaller ad units: under 30% of the page on phones, no sticky unit over 30% of the screen, and reserve each slot's space so it does not shift the page", + }; +} + +/** VISUAL.md 3.24, on full passes: candidates by name (in steps), the regions, the shares; confirmed 2 s later. */ +export function* adHeavy(ctx: Ctx, out: Draft[]): Generator { + if (!ctx.on("ad-heavy")) return; + const cands: Element[] = []; + yield* adCandidates(ctx, cands); + if (!cands.length) return; + yield; + const measure = (r: Reader): { regions: { el: Element; region: Region }[]; s: AdShares; reason: AdReason | null } => { + const regions = measureRegions(r, cands); + const s = adShares( + regions.map((x) => x.region), + r.vw, + r.vh, + r.doc.documentElement.scrollHeight, + ); + return { regions, s, reason: adVerdict(s) }; + }; + const m = measure(ctx.r); + const reason = m.reason; + if (!reason || m.regions.some((x) => ctx.r.animating(x.el))) return; + const d = adDraft(ctx, m.regions, m.s, reason); + d.persistMs = 2000; + d.quiet = true; + d.recheck = (r) => { + const again = measure(r); + return again.reason === reason && !again.regions.some((x) => r.animating(x.el)) ? adDraft(ctx, again.regions, again.s, reason) : null; + }; + out.push(d); +} diff --git a/packages/runtime/src/visual/checks/warped.ts b/packages/runtime/src/visual/checks/warped.ts new file mode 100644 index 00000000..4c5dbc3a --- /dev/null +++ b/packages/runtime/src/visual/checks/warped.ts @@ -0,0 +1,581 @@ +// text-warped (docs/runtime/VISUAL.md 3.25): visible text a reader cannot read as text. Every reason is a measurement +// of boxes (Range line boxes, element rects, computed transform, font-size, letter-spacing, word-spacing) or of code +// points; nothing reads what the text says. parseLinear, chainVerdict, lineOverlap, clipCut, tofuChars and +// spacingVerdict are pure and unit-tested (test/visual-strange.test.ts). +// +// Thresholds: +// - LINE_OVERLAP 0.5: two line boxes overlap by half the smaller one's height (normal line-height from 1.0 leaves at +// most about 25% overlap of the font's content area), and OVERLAP_X_PX 8 horizontally. +// - CLIP_PX 4 and CLIP_VISIBLE 0.2..0.8: a line cut mid-way by its overflow box. +// - SKEW_DEG 20 (design slants are 8 to 15 degrees), STRETCH 2, a negative determinant for mirrored text. +// - WIDE_EM 0.5 (wider than a space, 0.25 to 0.33 em), TIGHT_EM -0.2 (display headings use -0.02 to -0.05 em). +// - TINY_PX 8 rendered, with a drawn line box under TINY_BOX_PX 10. + +import type { Ctx, Draft } from "../draft.js"; +import { named, px } from "../draft.js"; +import { parseColor } from "../math.js"; +import { containsDeep, parentOf, REPLACED, safeMatches, type Reader } from "../measure.js"; +import { skipText } from "./text.js"; + +export const LINE_OVERLAP = 0.5; +export const OVERLAP_X_PX = 8; +export const CLIP_PX = 4; +export const CLIP_VISIBLE_MIN = 0.2; +export const CLIP_VISIBLE_MAX = 0.8; +export const SKEW_DEG = 20; +export const STRETCH = 2; +export const WIDE_EM = 0.5; +export const TIGHT_EM = -0.2; +export const TINY_PX = 8; +export const TINY_BOX_PX = 10; +/** Ancestors read for the transform chain, and for the clipping box. */ +const CHAIN = 30; +const CLIP_UP = 3; +/** Line boxes read per text node. */ +const LINES = 20; + +export type WarpReason = "overlap" | "clipped" | "tofu" | "mirrored" | "skewed" | "spacing" | "tiny"; + +// ------------------------------------------------------------------------------------------------ transforms + +/** A 2 x 2 linear map [a, b, c, d]: x' = a x + c y, y' = b x + d y (CSS matrix() order). */ +export type Linear = [number, number, number, number]; +export const IDENTITY: Linear = [1, 0, 0, 1]; + +export function mul(m: Linear, n: Linear): Linear { + return [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3]]; +} + +function angle(v: string): number | null { + const m = /^(-?[\d.]+(?:e-?\d+)?)(deg|rad|turn|grad)?$/.exec(v.trim()); + if (!m) return null; + const n = parseFloat(m[1]); + const u = m[2] ?? "deg"; + return u === "deg" ? (n * Math.PI) / 180 : u === "rad" ? n : u === "turn" ? n * 2 * Math.PI : (n * Math.PI) / 200; +} + +/** + * The linear part of computed `transform`, `rotate` and `scale` values (null: not parsed, the element is skipped). + * matrix3d is projected onto the screen plane (its upper-left 2 x 2, perspective ignored): rotateY(180deg) mirrors. + */ +export function parseLinear(transform: string, rotate = "none", scale = "none"): Linear | null { + let t: Linear = IDENTITY; + const tr = (transform || "none").trim(); + if (tr !== "none") { + const m = /^matrix(3d)?\(([^)]*)\)$/.exec(tr); + if (!m) return null; + const v = m[2].split(",").map((x) => parseFloat(x)); + if (v.some((x) => !Number.isFinite(x))) return null; + if (m[1] ? v.length !== 16 : v.length !== 6) return null; + t = m[1] ? [v[0], v[1], v[4], v[5]] : [v[0], v[1], v[2], v[3]]; + } + let r: Linear = IDENTITY; + const ro = (rotate || "none").trim(); + if (ro !== "none") { + const p = ro.split(/\s+/); + const a = angle(p[p.length - 1]); + if (a === null) return null; + let ax: [number, number, number] = [0, 0, 1]; + if (p.length === 2) ax = p[0] === "x" ? [1, 0, 0] : p[0] === "y" ? [0, 1, 0] : [0, 0, 1]; + else if (p.length === 4) ax = [parseFloat(p[0]), parseFloat(p[1]), parseFloat(p[2])]; + const len = Math.hypot(...ax); + if (!(len > 0)) return null; + const [x, y, z] = ax.map((q) => q / len); + const c = Math.cos(a); + const s = Math.sin(a); + // the upper-left 2 x 2 of the 3D rotation about (x, y, z) + r = [c + x * x * (1 - c), y * x * (1 - c) + z * s, x * y * (1 - c) - z * s, c + y * y * (1 - c)]; + } + let sc: Linear = IDENTITY; + const sv = (scale || "none").trim(); + if (sv !== "none") { + const p = sv.split(/\s+/).map((q) => (q.endsWith("%") ? parseFloat(q) / 100 : parseFloat(q))); + if (!p.length || p.some((q) => !Number.isFinite(q))) return null; + sc = [p[0], 0, 0, p[1] ?? p[0]]; + } + // CSS Transforms 2: translate, then rotate, then scale, then transform + return mul(mul(r, sc), t); +} + +export interface ChainVerdict { + det: number; + /** Degrees away from a right angle between the mapped axes. */ + skew: number; + /** The longer mapped axis over the shorter. */ + stretch: number; + /** sqrt(|det|): how much the text is scaled. */ + scale: number; + /** No rotation or skew: boxes from getClientRects are the text's own boxes. */ + aligned: boolean; +} + +export function chainVerdict(m: Linear): ChainVerdict { + const [a, b, c, d] = m; + const det = a * d - b * c; + const lu = Math.hypot(a, b); + const lv = Math.hypot(c, d); + const cos = lu > 0 && lv > 0 ? (a * c + b * d) / (lu * lv) : 0; + const skew = Math.abs(90 - (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI); + const eps = 1e-3; + return { + det, + skew, + stretch: lu > 0 && lv > 0 ? Math.max(lu, lv) / Math.min(lu, lv) : Infinity, + scale: Math.sqrt(Math.abs(det)), + aligned: Math.abs(b) < eps && Math.abs(c) < eps, + }; +} + +/** The transform product from the element up to the root, and the element in it that does the most. */ +export function transformChain(r: Reader, el: Element): { m: Linear; culprit: Element | null; backface: boolean } | null { + let m: Linear = IDENTITY; + let culprit: Element | null = null; + let worst = 0; + let backface = false; + let n = 0; + for (let x: Element | null = el; x && n < CHAIN; x = parentOf(x), n++) { + const s = r.cs(x); + const own = parseLinear(s.transform, (s as CSSStyleDeclaration & { rotate?: string }).rotate, (s as CSSStyleDeclaration & { scale?: string }).scale); + if (!own) return null; + if ((s as CSSStyleDeclaration & { backfaceVisibility?: string }).backfaceVisibility === "hidden") backface = true; + const v = chainVerdict(own); + const how = (v.det < 0 ? 100 : 0) + v.skew + Math.abs(Math.log(v.stretch || 1)) * 10; + if (how > worst) { + worst = how; + culprit = x; + } + m = mul(own, m); + } + return { m, culprit, backface }; +} + +// ------------------------------------------------------------------------------------------------ line boxes + +export interface Rect { + left: number; + top: number; + right: number; + bottom: number; +} + +/** Vertical overlap over the smaller height, when the boxes also overlap by OVERLAP_X_PX horizontally (else 0). */ +export function lineOverlap(a: Rect, b: Rect): number { + const x = Math.min(a.right, b.right) - Math.max(a.left, b.left); + if (x < OVERLAP_X_PX) return 0; + const y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + const h = Math.min(a.bottom - a.top, b.bottom - b.top); + return h > 0 && y > 0 ? y / h : 0; +} + +/** The largest overlap between two lines of one text node (pairs of its line boxes). */ +export function selfOverlap(lines: Rect[]): { f: number; i: number; j: number } { + let best = { f: 0, i: -1, j: -1 }; + for (let i = 0; i < lines.length; i++) + for (let j = i + 1; j < lines.length; j++) { + const f = lineOverlap(lines[i], lines[j]); + if (f > best.f) best = { f, i, j }; + } + return best; +} + +/** + * How a clipping box cuts a line: "x" when the line runs CLIP_PX past a side edge with CLIP_PX of it inside, "y" + * when only CLIP_VISIBLE_MIN..CLIP_VISIBLE_MAX of its height is inside, with the px cut off. + */ +export function clipCut(line: Rect, clip: Rect, axes: { x: boolean; y: boolean }): { axis: "x" | "y"; px: number; visible?: number } | null { + if (axes.x) { + if (line.right > clip.right + CLIP_PX && line.left < clip.right - CLIP_PX) return { axis: "x", px: line.right - clip.right }; + if (line.left < clip.left - CLIP_PX && line.right > clip.left + CLIP_PX) return { axis: "x", px: clip.left - line.left }; + } + if (axes.y) { + const h = line.bottom - line.top; + if (h <= 0) return null; + const vis = Math.max(0, Math.min(line.bottom, clip.bottom) - Math.max(line.top, clip.top)) / h; + if (vis >= CLIP_VISIBLE_MIN && vis <= CLIP_VISIBLE_MAX) return { axis: "y", px: h * (1 - vis), visible: vis }; + } + return null; +} + +// ------------------------------------------------------------------------------------------------ code points + +const NO_GLYPH = /[\uD800-\uDFFF]|\p{Cn}/gu; + +/** Lone surrogates and unassigned code points (noncharacters included): no font draws them. At most 5, as U+XXXX. */ +export function tofuChars(text: string): string[] { + const out: string[] = []; + for (const m of text.slice(0, 2000).matchAll(NO_GLYPH)) { + const cp = m[0].codePointAt(0) ?? 0; + const u = `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`; + if (!out.includes(u)) out.push(u); + if (out.length >= 5) break; + } + return out; +} + +// ------------------------------------------------------------------------------------------------ spacing + +/** VISUAL.md 3.25 spacing: em values of computed letter-spacing and word-spacing, and the words of the text. */ +export function spacingVerdict(o: { letterEm: number; wordEm: number; words: number; letters: number }): "wide" | "tight" | "words" | null { + if (o.letterEm >= WIDE_EM && o.words >= 2) return "wide"; + if (o.letterEm <= TIGHT_EM && o.letters >= 3) return "tight"; + if (o.wordEm <= TIGHT_EM && o.words >= 2) return "words"; + return null; +} + +const em = (v: string, fs: number): number => { + const n = parseFloat(v); + return Number.isFinite(n) && fs > 0 ? n / fs : 0; +}; + +// ------------------------------------------------------------------------------------------------ measuring + +function firstText(el: Element): Text | null { + for (const n of el.childNodes) if (n.nodeType === 3 && (n as Text).data.trim()) return n as Text; + return null; +} + +function lineBoxes(r: Reader, t: Text): Rect[] { + try { + const range = r.doc.createRange(); + range.selectNodeContents(t); + r.reads++; + return [...range.getClientRects()] + .filter((x) => x.width > 0 && x.height > 0) + .slice(0, LINES) + .map((x) => ({ left: x.left, top: x.top, right: x.right, bottom: x.bottom })); + } catch { + return []; + } +} + +const lettersOf = (s: string): number => (s.match(/\p{L}/gu) ?? []).length; +const wordsOf = (s: string): number => (s.match(/\p{L}{2,}/gu) ?? []).length; + +/** text-overflow: ellipsis on the element or one of its 3 nearest ancestors (the block container that truncates). */ +function ellipsis(r: Reader, el: Element): boolean { + let n = 0; + for (let x: Element | null = el; x && n <= 3; x = parentOf(x), n++) if (r.cs(x).textOverflow === "ellipsis") return true; + return false; +} + +/** Opaque text colour, no partial opacity: text a reader is meant to see in full. */ +function solid(r: Reader, el: Element): boolean { + const s = r.cs(el); + const c = parseColor((s as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor || s.color); + return !!c && c[3] >= 0.9 && !r.translucent(el); +} + +/** The element or a near ancestor scrolls on its own (its overflow is reachable, not cut). */ +function scroller(r: Reader, el: Element): boolean { + let n = 0; + for (let x: Element | null = el; x && n < 8 && x !== r.doc.body; x = parentOf(x), n++) { + const s = r.cs(x); + if (/auto|scroll/.test(s.overflowX + s.overflowY) && (x.scrollWidth > x.clientWidth + 1 || x.scrollHeight > x.clientHeight + 1)) return true; + } + return false; +} + +/** The nearest ancestor-or-self (CLIP_UP levels) whose overflow hides on some axis: its padding box and axes. */ +function clipBox(r: Reader, el: Element): { el: Element; box: Rect; x: boolean; y: boolean } | null { + let n = 0; + for (let x: Element | null = el; x && n <= CLIP_UP; x = parentOf(x), n++) { + if (x === r.doc.body || x === r.doc.documentElement) return null; + const s = r.cs(x); + const hx = s.overflowX === "hidden" || s.overflowX === "clip"; + const hy = s.overflowY === "hidden" || s.overflowY === "clip"; + if (!hx && !hy) continue; + const rc = r.rect(x); + const bl = parseFloat(s.borderLeftWidth) || 0; + const br = parseFloat(s.borderRightWidth) || 0; + const bt = parseFloat(s.borderTopWidth) || 0; + const bb = parseFloat(s.borderBottomWidth) || 0; + return { el: x, box: { left: rc.left + bl, top: rc.top + bt, right: rc.right - br, bottom: rc.bottom - bb }, x: hx, y: hy }; + } + return null; +} + +/** + * The cut is intended: from the element up to its clipping box, an ellipsis, a line clamp or a moving layer (a + * transform: carousel tracks, marquees); from the element up to 3 levels above the box, a mask (a "read more" fade). + */ +function intendedCut(r: Reader, el: Element, clip: Element): boolean { + let below = true; + let above = 0; + for (let x: Element | null = el; x && above <= 3; x = parentOf(x)) { + const s = r.cs(x) as CSSStyleDeclaration & { webkitLineClamp?: string; webkitMaskImage?: string; maskImage?: string }; + if ((s.maskImage && s.maskImage !== "none") || (s.webkitMaskImage && s.webkitMaskImage !== "none")) return true; + if (below) { + if (s.textOverflow === "ellipsis") return true; + if (s.webkitLineClamp && s.webkitLineClamp !== "none") return true; + if (s.transform && s.transform !== "none") return true; + } else above++; + if (x === clip) below = false; + } + return false; +} + +/** What one element measures (null: nothing to report, or skipped). */ +export interface WarpRead { + reason: WarpReason; + measured: Record; + /** The element the fix is on (the transform's owner, the clipping box), when it is not the text's own. */ + on?: Element; + text: string; + css?: string; + fixText?: string; +} + +const two = (x: number): number => Math.round(x * 100) / 100; + +/** Text this check never judges: text-invisible's skips (skipText), ruby annotations (they sit over their base). */ +function skipWarp(r: Reader, el: Element): boolean { + return !el.isConnected || skipText(el) || safeMatches(el, "rt, rp, rt *, rp *") || !r.visible(el); +} + +/** VISUAL.md 3.25 for one element, the reasons in order: tofu, mirrored, skewed, spacing, tiny, overlap, clipped. */ +export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { + if (skipWarp(r, el) || r.animating(el)) return null; + const t = firstText(el); + if (!t) return null; + const data = t.data; + const s = r.cs(el); + const fs = parseFloat(s.fontSize) || 0; + const who = named(ctx, el); + + const tofu = tofuChars(data); + if (tofu.length) { + const lines = lineBoxes(r, t); + if (lines.length) + return { + reason: "tofu", + measured: { reason: "tofu", codePoints: tofu.join(" "), count: tofu.length }, + text: `${who} shows ${tofu.length === 1 ? "a character" : `${tofu.length} characters`} no font can draw (${tofu.join(", ")}: ${tofu.some((u) => /^U\+D[89A-F]/.test(u)) ? "half of a surrogate pair, a string cut through an emoji or another character outside the BMP" : "unassigned in Unicode"}); browsers draw them as boxes.`, + fixText: "cut strings by code point, never by UTF-16 index (Array.from(s).slice(0, n), or Intl.Segmenter for user-perceived characters), and never store or send half a surrogate pair", + }; + } + + const chain = transformChain(r, el); + if (!chain) return null; + const v = chainVerdict(chain.m); + const by = chain.culprit && chain.culprit !== el ? chain.culprit : null; + const bySel = chain.culprit ? ctx.sel(chain.culprit) : ctx.sel(el); + if (v.det < 0 && !chain.backface && lettersOf(data) >= 3) + return { + reason: "mirrored", + on: by ?? undefined, + measured: { reason: "mirrored", determinant: two(v.det), transformOn: bySel }, + text: `${who} is mirrored by a transform${by ? ` on ${bySel}` : ""} (determinant ${two(v.det)}): the text reads backwards.`, + css: "transform: none;", + }; + if ((v.skew > SKEW_DEG || v.stretch >= STRETCH) && lettersOf(data) >= 3) + return { + reason: "skewed", + on: by ?? undefined, + measured: { reason: "skewed", skewDeg: Math.round(v.skew), stretch: two(v.stretch), transformOn: bySel }, + text: + v.skew > SKEW_DEG + ? `${who} is skewed ${Math.round(v.skew)}° by a transform${by ? ` on ${bySel}` : ""} (above ${SKEW_DEG}°).` + : `${who} is stretched ${two(v.stretch)}x on one axis by a transform${by ? ` on ${bySel}` : ""} (${STRETCH}x or more).`, + css: "transform: none;", + }; + + const sp = spacingVerdict({ letterEm: em(s.letterSpacing, fs), wordEm: em(s.wordSpacing, fs), words: wordsOf(data), letters: lettersOf(data) }); + if (sp) + return { + reason: "spacing", + measured: { reason: "spacing", letterSpacingEm: two(em(s.letterSpacing, fs)), wordSpacingEm: two(em(s.wordSpacing, fs)), fontPx: two(fs) }, + text: + sp === "wide" + ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who} spaces letters wider than a space (${WIDE_EM} em or more), so its words fall apart.` + : sp === "tight" + ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who} runs its letters into each other (${TIGHT_EM} em or less).` + : `word-spacing ${two(em(s.wordSpacing, fs))} em on ${who} removes the space between words (${TIGHT_EM} em or less).`, + css: sp === "words" ? "word-spacing: normal;" : "letter-spacing: 0.1em;", + }; + + const lines = v.aligned ? lineBoxes(r, t) : []; + const rendered = fs * v.scale; + if (rendered > 0 && rendered < TINY_PX && lettersOf(data) >= 3 && solid(r, el) && s.pointerEvents !== "none") { + const h = lines.length ? Math.max(...lines.map((l) => l.bottom - l.top)) : 0; + if (h > 0 && h < TINY_BOX_PX) + return { + reason: "tiny", + on: v.scale < 0.999 && by ? by : undefined, + measured: { reason: "tiny", renderedPx: two(rendered), fontPx: two(fs), scale: two(v.scale), lineBoxPx: two(h) }, + text: `${who} is drawn at ${two(rendered)} px${v.scale < 0.999 ? ` (${two(fs)} px text scaled by ${two(v.scale)}${by ? ` on ${bySel}` : ""})` : ""}, below ${TINY_PX} px, with a line box ${px(h)} tall.`, + css: v.scale < 0.999 ? "transform: none;" : "font-size: 12px;", + }; + } + if (!lines.length) return null; + + // an ellipsis gives a truncated line two boxes (the whole run and the part shown), one inside the other + const o = ellipsis(r, el) ? { f: 0, i: -1, j: -1 } : selfOverlap(lines); + if (o.f >= LINE_OVERLAP) { + const a = lines[o.i]; + const b = lines[o.j]; + return { + reason: "overlap", + measured: { reason: "overlap", overlap: two(o.f), lineBoxPx: two(Math.min(a.bottom - a.top, b.bottom - b.top)), lineHeight: s.lineHeight, fontPx: two(fs) }, + text: `lines of text overlap on ${who}: one line covers ${Math.round(o.f * 100)}% of the next one's height (line-height ${s.lineHeight} for ${px(fs)} text; threshold ${LINE_OVERLAP * 100}%).`, + css: "line-height: 1.4;", + }; + } + + const clip = clipBox(r, el); + if (clip && clip.box.right - clip.box.left >= 20 && !intendedCut(r, el, clip.el) && !scroller(r, el) && !r.animating(clip.el)) { + const cut = clipCut(lines[0], clip.box, { x: clip.x, y: clip.y }); + if (cut) { + const cs = ctx.sel(clip.el); + return { + reason: "clipped", + on: clip.el !== el ? clip.el : undefined, + measured: { reason: "clipped", axis: cut.axis, cutPx: Math.round(cut.px), ...(cut.visible !== undefined ? { visible: two(cut.visible) } : {}), clipOn: cs }, + text: + cut.axis === "x" + ? `${who} is cut mid-line: ${px(cut.px)} of its first line are hidden past the edge of ${cs} (overflow hidden, no ellipsis).` + : `${who} is cut through its first line: ${Math.round((cut.visible ?? 0) * 100)}% of the line's height shows inside ${cs} (overflow hidden).`, + css: cut.axis === "x" ? "text-overflow: ellipsis;" : "height: auto;", + }; + } + } + return null; +} + +function warpDraft(ctx: Ctx, el: Element, w: WarpRead): Draft { + const target = w.on ?? el; + const sel = ctx.sel(target); + return { + kind: "text-warped", + el, + key: `text-warped|${ctx.sel(el)}|${w.reason}`, + severity: "medium", + measured: w.measured, + threshold: thresholdOf(w.reason), + text: w.text, + ...(w.css ? { suggestion: { selector: sel, css: w.css, ...(w.reason === "clipped" ? { note: "Or give the box room for its text (remove the fixed width or height)." } : {}) } } : {}), + ...(w.fixText ? { fixText: w.fixText } : {}), + }; +} + +function thresholdOf(reason: WarpReason): Draft["threshold"] { + const src = "measured line boxes, transforms and spacing; VISUAL.md 3.25"; + switch (reason) { + case "overlap": + return { value: LINE_OVERLAP, unit: "of the smaller line box's height", source: src }; + case "clipped": + return { value: CLIP_PX, unit: "px of a line past its overflow box, or 20 to 80% of its height shown", source: src }; + case "tofu": + return { value: "lone surrogate or unassigned code point", unit: "code point", source: "Unicode General_Category Cn and Cs: no font has a glyph" }; + case "mirrored": + return { value: 0, unit: "transform determinant (below: mirrored)", source: src }; + case "skewed": + return { value: SKEW_DEG, unit: `degrees of skew, or ${STRETCH}x stretch`, source: src }; + case "spacing": + return { value: WIDE_EM, unit: `em letter-spacing (or ${TIGHT_EM} em and below)`, source: "wider than a space (0.25 to 0.33 em); VISUAL.md 3.25" }; + case "tiny": + return { value: TINY_PX, unit: "px rendered", source: src }; + } +} + +/** Two candidates' first lines overlap and neither hides the other (VISUAL.md 3.25 overlap between elements). */ +export function crossOverlap(r: Reader, a: Element, b: Element): { f: number; ra: Rect; rb: Rect } | null { + if (containsDeep(a, b) || containsDeep(b, a)) return null; + const ta = firstText(a); + const tb = firstText(b); + if (!ta || !tb || lettersOf(ta.data) < 3 || lettersOf(tb.data) < 3) return null; + const la = lineBoxes(r, ta)[0]; + const lb = lineBoxes(r, tb)[0]; + if (!la || !lb) return null; + const f = lineOverlap(la, lb); + if (f < LINE_OVERLAP) return null; + const fa = parseFloat(r.cs(a).fontSize) || 0; + const fb = parseFloat(r.cs(b).fontSize) || 0; + if (!(fa > 0 && fb > 0) || Math.max(fa, fb) > 2 * Math.min(fa, fb)) return null; + if (r.pinned(a) || r.pinned(b) || !solid(r, a) || !solid(r, b)) return null; + const x = Math.floor((Math.max(la.left, lb.left) + Math.min(la.right, lb.right)) / 2); + const y = Math.floor((Math.max(la.top, lb.top) + Math.min(la.bottom, lb.bottom)) / 2); + const st = r.stack(x, y); + const ia = st.findIndex((e) => containsDeep(a, e)); + const ib = st.findIndex((e) => containsDeep(b, e)); + if (ia < 0 || ib < 0) return null; + const [top, low] = ia < ib ? [a, b] : [b, a]; + for (let i = Math.min(ia, ib); i < Math.max(ia, ib); i++) { + const e = st[i]; + if (containsDeep(low, e) || containsDeep(e, low)) continue; + if (containsDeep(top, e) && e !== top && !REPLACED.has(e.tagName.toUpperCase())) continue; + const s = r.cs(e); + const bg = parseColor(s.backgroundColor); + if ((bg && bg[3] > 0.5) || (s.backgroundImage && s.backgroundImage !== "none") || REPLACED.has(e.tagName.toUpperCase())) return null; + } + return { f, ra: la, rb: lb }; +} + +function crossDraft(ctx: Ctx, a: Element, b: Element, f: number): Draft { + return { + kind: "text-warped", + el: a, + key: `text-warped|${ctx.sel(a)}|overlap|${ctx.sel(b)}`, + severity: "medium", + measured: { reason: "overlap", overlap: two(f), with: ctx.sel(b) }, + threshold: thresholdOf("overlap"), + text: `the text of ${named(ctx, a)} overlaps the text of ${named(ctx, b)} by ${Math.round(f * 100)}% of the smaller line's height, with nothing between them (threshold ${LINE_OVERLAP * 100}%).`, + fixText: "give the positioned element its own space (remove the absolute position or negative margin that moves it over the other text), or put a background behind it", + }; +} + +/** + * VISUAL.md 3.25 over the text candidates (at most 200), one element per step; then the pairwise first-line test, + * hit-testing only the pairs whose boxes overlap. Drafts are confirmed 1 s later by the same measurement. + */ +export function* measureWarped(ctx: Ctx, cands: Element[], out: Draft[]): Generator { + if (!ctx.on("text-warped")) return; + const r = ctx.r; + const els = cands.slice(0, 200); + const flagged = new Set(); + for (const el of els) { + yield; + const w = readWarped(ctx, r, el); + if (!w) continue; + flagged.add(el); + const d = warpDraft(ctx, el, w); + d.persistMs = 1000; + d.recheck = (r2) => { + const again = readWarped(ctx, r2, el); + return again && again.reason === w.reason ? warpDraft(ctx, el, again) : null; + }; + out.push(d); + } + yield; + // first lines of the elements whose boxes are their own (no rotation or skew), for the pairwise test + const boxes: { el: Element; box: Rect }[] = []; + for (const el of els) { + if (flagged.has(el) || skipWarp(r, el)) continue; + const t = firstText(el); + if (!t || lettersOf(t.data) < 3) continue; + const chain = transformChain(r, el); + if (!chain || !chainVerdict(chain.m).aligned) continue; + const l = lineBoxes(r, t)[0]; + if (l) boxes.push({ el, box: l }); + } + yield; + let tests = 0; + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) { + if (lineOverlap(boxes[i].box, boxes[j].box) < LINE_OVERLAP) continue; + if (++tests > 50) return; + yield; + const a = boxes[i].el; + const b = boxes[j].el; + if (r.animating(a) || r.animating(b)) continue; + const c = crossOverlap(r, a, b); + if (!c) continue; + const d = crossDraft(ctx, a, b, c.f); + d.persistMs = 1000; + d.recheck = (r2) => { + if (r2.animating(a) || r2.animating(b)) return null; + const again = crossOverlap(r2, a, b); + return again ? crossDraft(ctx, a, b, again.f) : null; + }; + out.push(d); + } +} diff --git a/packages/runtime/src/visual/engine.ts b/packages/runtime/src/visual/engine.ts index 92341ac1..60c59240 100644 --- a/packages/runtime/src/visual/engine.ts +++ b/packages/runtime/src/visual/engine.ts @@ -26,6 +26,8 @@ import * as A from "./checks/assets.js"; import * as T from "./checks/text.js"; import * as L from "./checks/layout.js"; import * as I from "./checks/interact.js"; +import * as AD from "./checks/ads.js"; +import * as W from "./checks/warped.js"; export const KINDS: CheckKind[] = [ "image-broken", @@ -51,6 +53,8 @@ export const KINDS: CheckKind[] = [ "control-unreachable", "focus-obscured", "dialog-covered", + "ad-heavy", + "text-warped", ]; /** The near-free kinds: browser-reported facts and interaction hooks. Low-end and sampled-out sessions run only these. */ @@ -83,8 +87,8 @@ const MO_OPTS: MutationObserverInit = { childList: true, subtree: true, characte /** Mutation records per second above which the observer rests for 5 s (an animation library writing styles every frame). */ const MO_RATE = 3000; let engines = 0; -const TEXT = new Set(["text-invisible", "text-template-leak", "text-placeholder", "text-encoding", "page-overflow-x"]); -const LAYOUT = new Set(["page-overflow-x", "viewport-meta-missing", "control-unreachable", "focus-obscured", "dialog-covered", "click-blocked"]); +const TEXT = new Set(["text-invisible", "text-template-leak", "text-placeholder", "text-encoding", "page-overflow-x", "text-warped"]); +const LAYOUT = new Set(["page-overflow-x", "viewport-meta-missing", "control-unreachable", "focus-obscured", "dialog-covered", "click-blocked", "ad-heavy"]); export interface Cfg { checks: Record; @@ -785,14 +789,17 @@ export class Visual implements VisualController { yield* A.brokenCssImages(ctx, env, drafts); yield* L.pageOverflow(ctx, drafts); yield* self.cssVars(ctx, drafts); + yield* AD.adHeavy(ctx, drafts); } yield* L.distortedImages(ctx, rts, drafts); const text = ctx.on("text-template-leak") || ctx.on("text-placeholder") || ctx.on("text-encoding"); - if (text || ctx.on("text-invisible")) { - yield* T.walkText(ctx, rts, scan, 5000, ctx.on("text-invisible")); + const cands = ctx.on("text-invisible") || ctx.on("text-warped"); + if (text || cands) { + yield* T.walkText(ctx, rts, scan, 5000, cands); T.placeholderImages(ctx, rts, scan); T.patternDrafts(ctx, scan, drafts); yield* T.measureContrast(ctx, scan.cands, drafts, self.rest); + yield* W.measureWarped(ctx, scan.cands, drafts); } yield; self.dialogs(ctx, drafts); diff --git a/packages/runtime/test/analytics.test.ts b/packages/runtime/test/analytics.test.ts index db3c64b7..267d0e21 100644 --- a/packages/runtime/test/analytics.test.ts +++ b/packages/runtime/test/analytics.test.ts @@ -8,7 +8,7 @@ import { createRuntime } from "../src/index.js"; import type { RuntimeImpl } from "../src/runtime.js"; import { DATA_LIKE, type TelemetryClient } from "../src/telemetry/client.js"; import { fromPairs, readMetaConfig } from "../src/cdn/config.js"; -import { ANALYTICS_NOTICE, IDLE_MS, KEY_MAX, keyOf, type KeyContext } from "../src/analytics/index.js"; +import { ANALYTICS_NOTICE, IDLE_MS, KEY_MAX, keyOf, loadNav, PATH_MAX, popNav, type KeyContext } from "../src/analytics/index.js"; import { normalizePath } from "../src/util.js"; import type { TelemetryTransport } from "../src/types.js"; import { FakeClock, ScriptedDecider } from "./helpers.js"; @@ -338,6 +338,132 @@ describe("interaction analytics: what a page view records (ANALYTICS.md section }); }); +describe("interaction analytics: navigation paths (ANALYTICS.md section 11)", () => { + /** A history traversal as the browser reports it: the URL is already the new one when popstate fires. */ + function traverse(path: string, index?: number) { + window.history.replaceState({}, "", path); + if (index !== undefined) (window as unknown as { navigation: { currentEntry: { index: number } } }).navigation.currentEntry.index = index; + window.dispatchEvent(new Event("popstate")); + } + const pagehide = () => window.dispatchEvent(new Event("pagehide")); + afterEach(() => { + delete (window as unknown as Record).navigation; + }); + + it("how each view was reached, the previous route with the time between, and at pagehide the exit and the path", async () => { + document.body.innerHTML = ``; + Object.defineProperty(window, "navigation", { value: { currentEntry: { index: 0 } }, configurable: true, writable: true }); + for (const nav of [true, false]) { + (window as unknown as { navigation: { currentEntry: { index: number } } }).navigation.currentEntry.index = 0; + const { clock, transport, rt: r } = await arun({ nav }); + await clock.advance(1500); + window.history.pushState({}, "", "/pricing"); + (window as unknown as { navigation: { currentEntry: { index: number } } }).navigation.currentEntry.index = 1; + if (!nav) fire($("b"), "click"); // without the nav observer the route change is seen at the next input + await clock.advance(2500); + traverse("/", 0); + if (!nav) fire($("b"), "click"); + await clock.advance(500); + pagehide(); + const evs = ix(transport); + const starts = evs.filter((e) => e.start).map((e) => [e.route, e.nav, e.from, e.fromMs]); + expect(starts, `nav ${nav}`).toEqual([ + ["/", "load", undefined, undefined], + ["/pricing", "push", "/", 1500], + ["/", "back", "/pricing", 2500], + ]); + const last = evs[evs.length - 1]; + expect(last, `nav ${nav}`).toMatchObject({ route: "/", exit: true, end: true, path: ["/", "/pricing", "/"] }); + expect(evs.filter((e) => e.exit)).toHaveLength(1); + r.destroy(); + window.history.replaceState({}, "", "/"); + } + }); + + it("forward, and a traversal whose direction the browser does not expose", async () => { + expect(popNav(2, 1)).toBe("back"); + expect(popNav(1, 2)).toBe("forward"); + expect(popNav(undefined, 2)).toBe("pop"); + expect(popNav(2, 2)).toBe("pop"); + const { clock, transport } = await arun({ nav: true }); + window.history.pushState({}, "", "/a"); + await clock.advance(100); + traverse("/"); // no Navigation API in this environment + await clock.advance(100); + setVisibility("hidden"); + expect(ix(transport).filter((e) => e.start).map((e) => e.nav)).toEqual(["load", "push", "pop"]); + }); + + it("the page load's own type: reload and back_forward from Navigation Timing; load otherwise", () => { + const g = (type?: string) => ({ performance: { getEntriesByType: () => (type ? [{ type }] : []) } }); + expect(loadNav(g("reload"))).toBe("reload"); + expect(loadNav(g("back_forward"))).toBe("back_forward"); + expect(loadNav(g("navigate"))).toBe("load"); + expect(loadNav(g("prerender"))).toBe("load"); + expect(loadNav(g())).toBe("load"); + expect(loadNav({})).toBe("load"); + }); + + it("the first interaction per view: a click, or a focus on a form field after a key press; once per view, never text", async () => { + document.body.innerHTML = `Read the docs`; + const { clock, transport } = await arun({ nav: true }); + $("email").focus(); // the page moving focus is not the visitor's interaction + await clock.advance(1200); + key(document.body, "Tab"); + $("email").blur(); + const ev = new FocusEvent("focusin", { bubbles: true, composed: true }); + Object.defineProperty(ev, "isTrusted", { value: true }); + $("email").dispatchEvent(ev); + await clock.advance(800); + fire($("docs"), "click"); + window.history.pushState({}, "", "/docs"); + await clock.advance(3000); + fire(document.body, "click"); + setVisibility("hidden"); + const evs = ix(transport); + const firsts = evs.filter((e) => e.first).map((e) => [e.route, e.first, e.firstMs]); + expect(firsts).toEqual([ + ["/", "input#email", 1200], + ["/docs", "body", 3000], + ]); + const text = JSON.stringify(transport.sent); + for (const t of ["alice", "Read the docs"]) expect(text).not.toContain(t); + }); + + it("a restore from the back/forward cache starts a new path, reached by back_forward", async () => { + const { clock, transport } = await arun({ nav: true }); + window.history.pushState({}, "", "/a"); + await clock.advance(100); + pagehide(); + const show = new Event("pageshow"); + Object.defineProperty(show, "persisted", { value: true }); + window.dispatchEvent(show); + setVisibility("hidden"); + setVisibility("visible"); + await clock.advance(100); + setVisibility("hidden"); + const evs = ix(transport); + expect(evs.filter((e) => e.exit).map((e) => e.path)).toEqual([["/", "/a"]]); + expect(evs.filter((e) => e.start).map((e) => [e.route, e.nav, e.from])).toEqual([ + ["/", "load", undefined], + ["/a", "push", "/"], + ["/a", "back_forward", undefined], + ]); + }); + + it(`a path holds at most PATH_MAX (${PATH_MAX}) routes, then "(more)"`, async () => { + const { clock, transport } = await arun({ nav: true }); + for (let i = 1; i <= PATH_MAX + 3; i++) { + window.history.pushState({}, "", `/p${i}`); + await clock.advance(10); + } + pagehide(); + const exit = ix(transport).find((e) => e.exit)!; + expect((exit.path as string[]).length).toBe(PATH_MAX + 1); + expect((exit.path as string[]).at(-1)).toBe("(more)"); + }); +}); + describe("interaction analytics: element keys from markup only (ANALYTICS.md section 1)", () => { const k: KeyContext = { route: (p) => normalizePath(p), loc: { href: "https://shop.example/pricing", origin: "https://shop.example" }, url: URL, data: DATA_LIKE }; const keyFor = (html: string, sel = "#t") => { diff --git a/packages/runtime/test/browser/visual/fixtures/ads-interstitial.html b/packages/runtime/test/browser/visual/fixtures/ads-interstitial.html new file mode 100644 index 00000000..001434d8 --- /dev/null +++ b/packages/runtime/test/browser/visual/fixtures/ads-interstitial.html @@ -0,0 +1,26 @@ + + + + + +Interstitial ad + + + + +
+

Recipes for a rainy week

+

Five dinners that take less than thirty minutes, from a lentil soup to a tray of roasted vegetables with feta.

+

Each recipe lists what you can prepare the evening before, so the week stays quick.

+
+
+ + +
+ + diff --git a/packages/runtime/test/browser/visual/fixtures/ads.html b/packages/runtime/test/browser/visual/fixtures/ads.html new file mode 100644 index 00000000..9aa85c9e --- /dev/null +++ b/packages/runtime/test/browser/visual/fixtures/ads.html @@ -0,0 +1,41 @@ + + + + + +Ad-heavy page + + + + + +
+
+
+

Council approves the new library budget

+

The council voted on Tuesday to fund the library's renovation, which will add a reading room and longer opening hours on weekends.

+
+

Work starts in the spring. The library stays open during the first phase, with the children's section moving to the community hall.

+ +

Residents can comment on the plans until the end of the month at the town hall or by post.

+ +

The next council meeting is on the first Tuesday of next month.

+
+
+ +
+ + diff --git a/packages/runtime/test/browser/visual/fixtures/clean-strange.html b/packages/runtime/test/browser/visual/fixtures/clean-strange.html new file mode 100644 index 00000000..faaca439 --- /dev/null +++ b/packages/runtime/test/browser/visual/fixtures/clean-strange.html @@ -0,0 +1,81 @@ + + + + + +Clean page: ads and text that look strange but are correct + + + + + +
+
+

Product update

+

A display heading that wraps onto a second line

+
+

This page has two ad slots and two cross-site embeds, far below the ad thresholds, and text set in ways that look unusual but read fine.

+

A long title that ends in an ellipsis on purpose

+

A description clamped to two lines with an ellipsis: the rest of this sentence is hidden on purpose, as card layouts do.

+

A "read more" box shows the start of a long text and fades out the line it cuts through. The rest of the text appears when the visitor opens it, so the cut is a design.

+

Limited offer 1 漢字かんじ Great 👍 שלום עולם

+ Revenue +
Front of the card
Back of the card
+ +

Our year in review

+

A miniature preview of another page, scaled to a quarter and not meant to be read here.

Its second paragraph is just as small.

+
ProductNew

Card title next to a badge

+
+

縦書きの文章

+ +
+ +
+
ACME Ltd, 1 Example Street
+ + + diff --git a/packages/runtime/test/browser/visual/fixtures/warped.html b/packages/runtime/test/browser/visual/fixtures/warped.html new file mode 100644 index 00000000..47fde8d6 --- /dev/null +++ b/packages/runtime/test/browser/visual/fixtures/warped.html @@ -0,0 +1,48 @@ + + + + + +Warped text + + + + +
+

Lines of this paragraph sit on top of each other because its line height is far too small.

+

Delivery in two days

Free returns for a month

+
Free shipping on every single order
+
+

Loading

+

Mirrored headline text

+

Skewed label text

+

Summer sale

+

Terms and conditions apply to every order

+

Scaled down text that people still need to read

+

A tight display heading

+
Free shipping on every single order

New this week

Prices include VAT.

Sale
+
+ + + diff --git a/packages/runtime/test/browser/visual/fixtures/warped.js b/packages/runtime/test/browser/visual/fixtures/warped.js new file mode 100644 index 00000000..e7281dd4 --- /dev/null +++ b/packages/runtime/test/browser/visual/fixtures/warped.js @@ -0,0 +1,2 @@ +// A string cut through an emoji (a lone high surrogate): HTML cannot carry one, so the fixture sets it from script. +document.getElementById("cut").textContent = "Great deal \ud83d"; diff --git a/packages/runtime/test/browser/visual/visual.spec.ts b/packages/runtime/test/browser/visual/visual.spec.ts index f1924916..50a0204e 100644 --- a/packages/runtime/test/browser/visual/visual.spec.ts +++ b/packages/runtime/test/browser/visual/visual.spec.ts @@ -36,6 +36,8 @@ interface Seen { /** Open a fixture and wait until the first automatic pass has run. */ async function open(page: Page, path: string): Promise { await page.route("https://placehold.co/**", (r) => r.fulfill({ status: 200, contentType: "image/png", body: png(300, 120) })); + // cross-site frames for ad-heavy's fixtures (an ad server, a video player, a map): blank documents + await page.route(/^https:\/\/(ads|video|maps)\.example\.test\//, (r) => r.fulfill({ status: 200, contentType: "text/html", body: "frame" })); await page.goto(`${base}${path}`); expect(await page.evaluate(() => document.visibilityState)).toBe("visible"); await page.waitForFunction(() => ((window as unknown as { GenClass?: { runtime?: { summary(): { visual?: { passes: number } } } } }).GenClass?.runtime?.summary().visual?.passes ?? 0) >= 1, null, { timeout: 30_000 }); @@ -317,6 +319,53 @@ test.describe("measured visual checks (VISUAL.md section 14)", () => { }); }); +test.describe("ad-heavy and text-warped (VISUAL.md 3.24, 3.25)", () => { + test("ad-heavy: ad slots and ad-sized cross-site frames over 30% of the page", async ({ page }) => { + await open(page, "/f/ads.html"); + const found = await settle(page); + exact(found, await planted(page)); + const f = found.find((x) => x.kind === "ad-heavy")!; + expect(f.message).toMatch(/^\[GenClass\] Check \(measured\): ad slots cover \d+% of the page \(threshold 30%, Better Ads ad density\): 7 ad slots/); + const m = await page.evaluate(() => (window as unknown as { GenClass: { runtime: { checks(): { kind: string; measured: Record; severity: string; count?: number }[] } } }).GenClass.runtime.checks().find((c) => c.kind === "ad-heavy")); + expect(m!.measured.reason).toBe("density"); + expect(m!.measured.pageShare as number).toBeGreaterThanOrEqual(0.3); + expect(m!.severity).toBe("medium"); + expect(await page.evaluate(() => document.querySelectorAll("[data-genclass-fix]").length + document.adoptedStyleSheets.length)).toBe(0); + }); + + test("ad-heavy: a full-screen ad on top of the page is an interstitial (high)", async ({ page }) => { + await open(page, "/f/ads-interstitial.html"); + const found = await settle(page); + exact(found, await planted(page)); + const m = await page.evaluate(() => (window as unknown as { GenClass: { runtime: { checks(): { kind: string; measured: Record; severity: string }[] } } }).GenClass.runtime.checks().find((c) => c.kind === "ad-heavy")); + expect(m).toMatchObject({ severity: "high", measured: { reason: "interstitial", count: 1 } }); + }); + + test("text-warped: overlapping lines, cut lines, half an emoji, mirrored, skewed, spaced-out and tiny text", async ({ page }) => { + await open(page, "/f/warped.html"); + const found = await settle(page); + exact(found, await planted(page)); + const reasons = await page.evaluate(() => + Object.fromEntries((window as unknown as { GenClass: { runtime: { checks(): { kind: string; measured: Record; element(): Element | null }[] } } }).GenClass.runtime.checks().filter((c) => c.kind === "text-warped").map((c) => [c.element()?.getAttribute("data-k"), c.measured.reason])), + ); + expect(reasons).toEqual({ w1: "overlap", w2: "overlap", w3: "clipped", w4: "clipped", w5: "tofu", w6: "mirrored", w7: "skewed", w8: "spacing", w9: "tiny", w10: "tiny" }); + for (const f of found) expect(f.message).not.toMatch(/\(\w+, 0\.\d\d\)$/); + }); + + test("a clean page of ads and text that look strange but are correct: zero findings", async ({ page }) => { + await open(page, "/f/clean-strange.html"); + expect((await settle(page)).map((f) => f.message)).toEqual([]); + }); + + test("the same clean page on a phone (390 x 844): zero findings", async ({ browser }) => { + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); + const page = await ctx.newPage(); + await open(page, "/f/clean-strange.html"); + expect((await settle(page)).map((f) => f.message)).toEqual([]); + await ctx.close(); + }); +}); + test.describe("runtime fixes (guard mode, visual: { on: true, fix: true })", () => { test("text-invisible: an owned rule fixes the colour, setMode('observe') removes it", async ({ page }) => { await open(page, "/f/fix.html"); diff --git a/packages/runtime/test/visual-checks.test.ts b/packages/runtime/test/visual-checks.test.ts index 7e288421..03f47436 100644 --- a/packages/runtime/test/visual-checks.test.ts +++ b/packages/runtime/test/visual-checks.test.ts @@ -307,9 +307,9 @@ describe("findings: labelled as measured checks (VISUAL.md 1, 9.1, 9.2)", () => }); describe("options and helpers", () => { - it("23 kinds; the light tier keeps only the near-free ones", () => { - expect(KINDS.length).toBe(23); - for (const k of ["text-invisible", "text-template-leak", "css-var-undefined", "image-distorted", "page-overflow-x", "page-blank"] as const) expect(LIGHT.has(k)).toBe(false); + it("25 kinds; the light tier keeps only the near-free ones", () => { + expect(KINDS.length).toBe(25); + for (const k of ["text-invisible", "text-template-leak", "css-var-undefined", "image-distorted", "page-overflow-x", "page-blank", "ad-heavy", "text-warped"] as const) expect(LIGHT.has(k)).toBe(false); expect(LIGHT.has("image-broken")).toBe(true); }); diff --git a/packages/runtime/test/visual-strange.test.ts b/packages/runtime/test/visual-strange.test.ts new file mode 100644 index 00000000..ac1a965d --- /dev/null +++ b/packages/runtime/test/visual-strange.test.ts @@ -0,0 +1,186 @@ +// ad-heavy and text-warped (docs/runtime/VISUAL.md 3.24, 3.25) as pure functions: ad names and sizes, the union of +// boxes, the shares and their verdict; transform parsing and composition, line-box overlap, clipping, code points and +// spacing. Each threshold has a case just above and just below it, and clean controls next to the faults. +import { describe, expect, it } from "vitest"; +import { AD_FIRST_SHARE, AD_INTERSTITIAL_SHARE, AD_PAGE_SHARE, AD_STICKY_SHARE, adNamed, adShares, adSize, adVerdict, adWords, unionArea, type Region } from "../src/visual/checks/ads.js"; +import { + chainVerdict, + clipCut, + IDENTITY, + LINE_OVERLAP, + lineOverlap, + mul, + parseLinear, + selfOverlap, + SKEW_DEG, + spacingVerdict, + tofuChars, + type Linear, +} from "../src/visual/checks/warped.js"; + +const box = (left: number, top: number, w: number, h: number) => ({ left, top, right: left + w, bottom: top + h }); +const near = (m: Linear | null, want: Linear) => { + expect(m).not.toBeNull(); + m!.forEach((v, i) => expect(v).toBeCloseTo(want[i], 6)); +}; + +describe("ad-heavy: names, sizes, shares (VISUAL.md 3.24)", () => { + it("splits names into words and matches ad words only as whole words", () => { + expect(adWords("div-gpt-ad-1234-0")).toEqual(["div", "gpt", "ad"]); + expect(adWords("google_ads_iframe_/1/x_0")).toEqual(["google", "ads", "iframe", "x"]); + expect(adWords("headerAdSlot")).toEqual(["header", "ad", "slot"]); + for (const n of ["ad-slot", "AdBanner", "sidebarAd", "adsbygoogle", "advertisement", "sponsored-post", "top_ads"]) expect(adNamed(n, []), n).toBe(true); + for (const n of ["header", "shadow", "badge", "add-to-cart", "adaptive", "loading", "heading", "read-more", "iPad", "sponsors", "address"]) expect(adNamed(n, []), n).toBe(false); + expect(adNamed("", ["card", "ad"])).toBe(true); + expect(adNamed("", ["card"], ["data-ad-slot"])).toBe(true); + expect(adNamed("", ["card"], ["data-testid"])).toBe(false); + }); + + it("cross-site iframe sizes within 2 px of a fixed ad size", () => { + expect(adSize(300, 250)).toBe(true); + expect(adSize(302, 248)).toBe(true); + expect(adSize(303, 250)).toBe(false); + expect(adSize(728, 90)).toBe(true); + expect(adSize(560, 315)).toBe(false); // a video embed + expect(adSize(400, 300)).toBe(false); // a map + }); + + it("the union of boxes counts overlaps once and clips to the screen or page", () => { + expect(unionArea([box(0, 0, 10, 10)], box(0, 0, 100, 100))).toBe(100); + expect(unionArea([box(0, 0, 10, 10), box(5, 5, 10, 10)], box(0, 0, 100, 100))).toBe(175); + expect(unionArea([box(0, 0, 10, 10), box(0, 0, 10, 10)], box(0, 0, 100, 100))).toBe(100); + expect(unionArea([box(-5, -5, 10, 10)], box(0, 0, 100, 100))).toBe(25); + expect(unionArea([box(200, 200, 10, 10)], box(0, 0, 100, 100))).toBe(0); + expect(unionArea([], box(0, 0, 100, 100))).toBe(0); + }); + + it("shares of the first screen, the page and the largest fixed region; verdict in order", () => { + const vw = 1000; + const vh = 800; + const flow = (top: number, h: number): Region => ({ box: box(0, top, vw, h), fixed: false }); + // a page 4,000 px tall with 1,190 px of full-width ads: 29.75% (below) and 1,210 px: 30.25% (above) + const below = adShares([flow(1000, 595), flow(2000, 595)], vw, vh, 4000); + expect(below.pageShare).toBeCloseTo(0.2975, 4); + expect(adVerdict(below)).toBeNull(); + const above = adShares([flow(1000, 605), flow(2000, 605)], vw, vh, 4000); + expect(above.pageShare).toBeGreaterThanOrEqual(AD_PAGE_SHARE); + expect(adVerdict(above)).toBe("density"); + // first screen: 49% and 51% of it + expect(adVerdict(adShares([flow(0, 392)], vw, vh, 10_000))).toBeNull(); + const first = adShares([flow(0, 408)], vw, vh, 10_000); + expect(first.firstShare).toBeGreaterThanOrEqual(AD_FIRST_SHARE); + expect(adVerdict(first)).toBe("first-screen"); + // a fixed bottom bar: 29% and 31% of the screen + expect(adVerdict(adShares([{ box: box(0, 568, vw, 232), fixed: true }], vw, vh, 10_000))).toBeNull(); + const sticky = adShares([{ box: box(0, 552, vw, 248), fixed: true }], vw, vh, 10_000); + expect(sticky.fixedShare).toBeGreaterThanOrEqual(AD_STICKY_SHARE); + expect(adVerdict(sticky)).toBe("sticky"); + // a fixed region that covers the screen and is on top: an interstitial; under other content: only sticky + const full = { box: box(0, 0, vw, vh), fixed: true }; + expect(adShares([{ ...full, onTop: true }], vw, vh, 10_000).topShare).toBeGreaterThanOrEqual(AD_INTERSTITIAL_SHARE); + expect(adVerdict(adShares([{ ...full, onTop: true }], vw, vh, 10_000))).toBe("interstitial"); + expect(adVerdict(adShares([{ ...full, onTop: false }], vw, vh, 10_000))).toBe("sticky"); + // fixed regions cover their share of every screen: a 20% fixed bar plus 15% in-flow ads is 1 - 0.8 x 0.85 = 32% + const mixed = adShares([{ box: box(0, 640, vw, 160), fixed: true }, flow(2000, 1500)], vw, vh, 10_000); + expect(mixed.pageShare).toBeCloseTo(0.32, 4); + expect(adVerdict(mixed)).toBe("density"); + // nothing measured: no verdict + expect(adVerdict(adShares([], vw, vh, 1000))).toBeNull(); + }); + + it("a page shorter than the screen is measured against the screen", () => { + const s = adShares([{ box: box(0, 0, 300, 250), fixed: false }], 1000, 800, 400); + expect(s.pageShare).toBeCloseTo(75_000 / 800_000, 6); + }); +}); + +describe("text-warped: transforms (VISUAL.md 3.25)", () => { + it("parses matrix(), matrix3d() and the individual rotate and scale properties", () => { + near(parseLinear("none"), IDENTITY); + near(parseLinear("matrix(-1, 0, 0, 1, 0, 0)"), [-1, 0, 0, 1]); + // rotateY(180deg): matrix3d(-1, 0, -1.2e-16, 0, 0, 1, 0, 0, 1.2e-16, 0, -1, 0, 0, 0, 0, 1) + near(parseLinear("matrix3d(-1, 0, -1.22465e-16, 0, 0, 1, 0, 0, 1.22465e-16, 0, -1, 0, 0, 0, 0, 1)"), [-1, 0, 0, 1]); + near(parseLinear("none", "none", "-1 1"), [-1, 0, 0, 1]); + near(parseLinear("none", "none", "50%"), [0.5, 0, 0, 0.5]); + near(parseLinear("none", "90deg"), [0, 1, -1, 0]); + near(parseLinear("none", "y 180deg"), [-1, 0, 0, 1]); + near(parseLinear("none", "0.25turn"), [0, 1, -1, 0]); + expect(parseLinear("perspective(10px)")).toBeNull(); + expect(parseLinear("matrix(1, 2)")).toBeNull(); + expect(parseLinear("none", "sideways")).toBeNull(); + }); + + it("mirrored, skewed, stretched, scaled; pure rotation is aligned only at 0 and 180 but never warped", () => { + expect(chainVerdict([-1, 0, 0, 1]).det).toBeLessThan(0); + expect(chainVerdict(mul([-1, 0, 0, 1], [-1, 0, 0, 1])).det).toBeGreaterThan(0); // mirrored twice reads fine + const skew = (deg: number): Linear => [1, 0, Math.tan((deg * Math.PI) / 180), 1]; + expect(chainVerdict(skew(SKEW_DEG + 1)).skew).toBeGreaterThan(SKEW_DEG); + expect(chainVerdict(skew(SKEW_DEG - 1)).skew).toBeLessThan(SKEW_DEG); + expect(chainVerdict(skew(-10)).skew).toBeCloseTo(10, 6); // a design slant + const rot = chainVerdict([Math.cos(1), Math.sin(1), -Math.sin(1), Math.cos(1)]); + expect(rot.skew).toBeCloseTo(0, 6); + expect(rot.stretch).toBeCloseTo(1, 6); + expect(rot.det).toBeCloseTo(1, 6); + expect(rot.aligned).toBe(false); + expect(chainVerdict([2, 0, 0, 1]).stretch).toBe(2); + expect(chainVerdict([1.9, 0, 0, 1]).stretch).toBeLessThan(2); + expect(chainVerdict([0.25, 0, 0, 0.25]).scale).toBeCloseTo(0.25, 6); + expect(chainVerdict(IDENTITY).aligned).toBe(true); + }); +}); + +describe("text-warped: line boxes, clipping, code points, spacing (VISUAL.md 3.25)", () => { + it("two lines overlap by half the smaller one's height, and by 8 px horizontally", () => { + // 20 px tall line boxes 9 px apart: 55% overlap; 11 px apart: 45% + expect(lineOverlap(box(0, 0, 200, 20), box(0, 9, 200, 20))).toBeGreaterThanOrEqual(LINE_OVERLAP); + expect(lineOverlap(box(0, 0, 200, 20), box(0, 11, 200, 20))).toBeLessThan(LINE_OVERLAP); + // the same row: side by side, never overlapping + expect(lineOverlap(box(0, 0, 100, 20), box(100, 0, 100, 20))).toBe(0); + expect(lineOverlap(box(0, 0, 100, 20), box(93, 0, 100, 20))).toBe(0); // 7 px: a kerning or italic overhang + // normal line-height 1.2 on a font whose content area is 1.15 em: no overlap + expect(lineOverlap(box(0, 0, 300, 18.4), box(0, 19.2, 300, 18.4))).toBe(0); + const lines = [box(0, 0, 300, 20), box(0, 8, 300, 20), box(0, 16, 120, 20)]; + expect(selfOverlap(lines)).toMatchObject({ i: 0, j: 1 }); + expect(selfOverlap(lines).f).toBeCloseTo(0.6, 6); + expect(selfOverlap([box(0, 0, 300, 20)]).f).toBe(0); + }); + + it("a line cut mid-way by its overflow box: 4 px past the edge, or 20 to 80% of its height inside", () => { + const clip = box(0, 0, 100, 30); + expect(clipCut(box(0, 5, 150, 20), clip, { x: true, y: false })).toMatchObject({ axis: "x", px: 50 }); + expect(clipCut(box(0, 5, 103, 20), clip, { x: true, y: false })).toBeNull(); // 3 px: rounding and overhang + expect(clipCut(box(0, 5, 105, 20), clip, { x: true, y: false })).toMatchObject({ axis: "x" }); + expect(clipCut(box(110, 5, 50, 20), clip, { x: true, y: false })).toBeNull(); // wholly outside: a carousel's next slide + expect(clipCut(box(-40, 5, 100, 20), clip, { x: true, y: false })).toMatchObject({ axis: "x", px: 40 }); // right-to-left + expect(clipCut(box(0, 5, 150, 20), clip, { x: false, y: true })).toBeNull(); // only the axes that hide count + // vertical: a 20 px line in a 30 px box starting at 20 px shows half of it; at 25 px a quarter; at 14 px 80% + expect(clipCut(box(0, 20, 80, 20), clip, { x: false, y: true })).toMatchObject({ axis: "y", visible: 0.5 }); + expect(clipCut(box(0, 25, 80, 20), clip, { x: false, y: true })).toMatchObject({ axis: "y", visible: 0.25 }); + expect(clipCut(box(0, 27, 80, 20), clip, { x: false, y: true })).toBeNull(); // 15%: hidden lines of a collapsed box + expect(clipCut(box(0, 13, 80, 20), clip, { x: false, y: true })).toBeNull(); // 85%: descenders cut + expect(clipCut(box(0, 5, 80, 20), clip, { x: true, y: true })).toBeNull(); // fits + }); + + it("lone surrogates and unassigned code points; real text, emoji and private-use icons are not tofu", () => { + expect(tofuChars("Café — naïve 中文 नमस्ते")).toEqual([]); + expect(tofuChars("Party \u{1F389}\u{1F44D}\u{1F3FD}")).toEqual([]); + expect(tofuChars(" icon")).toEqual([]); // private use: icon fonts (icon-font-failed's job) + expect(tofuChars("�")).toEqual([]); // the replacement character is text-encoding's + expect(tofuChars("Great \ud83d")).toEqual(["U+D83D"]); // an emoji cut in half + expect(tofuChars("\ude00 x")).toEqual(["U+DE00"]); + expect(tofuChars("x ͸ y")).toEqual(["U+0378"]); // unassigned + expect(tofuChars("﷐￿")).toEqual(["U+FDD0", "U+FFFF"]); // noncharacters + expect(tofuChars("\ud83d\ud83d\ud83d\ud83d\ud83d\ud83d").length).toBe(1); + }); + + it("letter spacing at 0.5 em with two words, -0.2 em, word spacing at -0.2 em; designs below them stay quiet", () => { + expect(spacingVerdict({ letterEm: 0.5, wordEm: 0, words: 2, letters: 9 })).toBe("wide"); + expect(spacingVerdict({ letterEm: 0.49, wordEm: 0, words: 2, letters: 9 })).toBeNull(); + expect(spacingVerdict({ letterEm: 0.8, wordEm: 0, words: 1, letters: 6 })).toBeNull(); // one spaced-out word: a logo style + expect(spacingVerdict({ letterEm: 0.2, wordEm: 0, words: 3, letters: 12 })).toBeNull(); // uppercase eyebrow tracking + expect(spacingVerdict({ letterEm: -0.2, wordEm: 0, words: 1, letters: 5 })).toBe("tight"); + expect(spacingVerdict({ letterEm: -0.05, wordEm: 0, words: 2, letters: 9 })).toBeNull(); // a tight display heading + expect(spacingVerdict({ letterEm: 0, wordEm: -0.2, words: 2, letters: 9 })).toBe("words"); + expect(spacingVerdict({ letterEm: 0, wordEm: -0.19, words: 2, letters: 9 })).toBeNull(); + }); +}); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index a2e508b5..1c326db5 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -75,7 +75,10 @@ genclass.dev/dashboard/ --> dashboard page --GET /api/projects/? | `OPTIONS /v1/events` | CORS preflight (`access-control-allow-origin: *`) | | `GET /v1/health` | `{ ok: true, schema }` | | `POST /api/projects` | body `{ name? }` (JSON object or empty; name sanitised, at most 80 chars, default "Untitled app"). 201 `{ token, dashboardUrl, name, created }`, `dashboardUrl` = `https://genclass.dev/dashboard/<32 base62>`. CORS `*`, no credentials. 429 JSON when rate-limited, 400 bad JSON, 413 over 4 KB. | -| `GET /api/projects/?range=24h\|7d\|30d` | dashboard stats JSON (every plan includes the dashboard; a plan file that left it out would answer 402 `{ ok:false, error, locked:true }`) (default `7d`): `name, token, created, lastEvent, range, from, generated, totals, breakdowns{metric: [{key,count}]}` (top 25 each; 200 for `detect_issue` and `possible_issue`), `distinct{metric: n}` (distinct keys before that cut, e.g. pages with a problem), `series` (24 hourly or 7/30 daily buckets: sessions, decisions, detections, acted, possible, ix_views), `recent` (last 50 detections/interventions in the window; possible ones are not listed). `totals` also has `latencyAvgMs`, `modelLoadAvgMs`, `failOpens`, `modelErrors`, `interventions`, `actionLimits`, `detectionsWithPage` and `possibleWithPage`. Apps that turned on interaction analytics (runtime `interactions` events, [docs/runtime/ANALYTICS.md](../docs/runtime/ANALYTICS.md)) also get `ix_views`, `ix_engaged_ms`, `ix_click`, `ix_rage` and `ix_dead` totals and breakdowns (by route, or route TAB element key; `ix_scroll` by route TAB milestone; top 200 each), counted with their own per-batch budget. 404 `{ ok:false, error:"not found" }` for an unknown or malformed secret. Same-origin only (no CORS). | +| `GET /api/projects/?range=24h\|7d\|30d` | dashboard stats JSON (every plan includes the dashboard; a plan file that left it out would answer 402 `{ ok:false, error, locked:true }`) (default `7d`): `name, token, created, lastEvent, range, from, generated, totals, breakdowns{metric: [{key,count}]}` (top 25 each; 200 for `detect_issue` and `possible_issue`), `distinct{metric: n}` (distinct keys before that cut, e.g. pages with a problem), `series` (24 hourly or 7/30 daily buckets: sessions, decisions, detections, acted, possible, ix_views), `recent` (last 50 detections/interventions in the window; possible ones are not listed). `totals` also has `latencyAvgMs`, `modelLoadAvgMs`, `failOpens`, `modelErrors`, `interventions`, `actionLimits`, `detectionsWithPage` and `possibleWithPage`. Apps that turned on interaction analytics (runtime `interactions` events, [docs/runtime/ANALYTICS.md](../docs/runtime/ANALYTICS.md)) also get `ix_views`, `ix_engaged_ms`, `ix_click`, `ix_rage` and `ix_dead` totals and breakdowns (by route, or route TAB element key; `ix_scroll` by route TAB milestone; top 200 each), counted with their own per-batch budget; with navigation paths +(ANALYTICS.md section 11) also `ix_entry` and `ix_exit` (totals and by route), `ix_nav` and `ix_nav_ms` (from TAB to), +`ix_arrive` (by `nav`, and route TAB `nav`), `ix_first` (route TAB element key), `ix_first_n` and `ix_first_ms` (by +route) and `ix_path` (the first 5 routes of a page load, joined with ` > `). 404 `{ ok:false, error:"not found" }` for an unknown or malformed secret. Same-origin only (no CORS). | | `GET /start` | "Get a token" page | | `POST /api/projects//agent` | what a developer's coding agent reports about the app through `@genclass/mcp` when they set `GENCLASS_REPORT` (docs/runtime/AGENT.md section 4.5): `{ schema: "genclass-agent/1", events: [{ t: "found" \| "replay" \| "test", id, kind, route, at, reproduced? }] }`, at most 50 events and 16 KB a request, rate limited per token (`SESSION_LIMIT`). Stored per finding in `agent_reports` (kind, normalised route, found, last replay and verdict, tested, proven: a replay that stops reproducing after one that did), 90 days; the stats carry them as `agent` and the dashboard's Loop view shows them. The sample and unknown secrets 404; other methods 405. Ids, kinds, routes, verdicts and times only, never page text | | `GET /dashboard/` | the dashboard (for an app with no account too: it is on Free), in views a sidebar switches between: Overview (the summary, the tiles, the trend), Problems, Pages and Actions, Activity, Visitors, Health, Loop (Fig. 3: the closed loop from a problem on this app to a proven, shipped fix, with the app's own numbers on the stages that happen here) and Setup on a private link (Connect, Forwarding and Export in account mode); the hash names the view (`#found`, `#recent-s`), the header has a breadcrumb and the Dashboard guide (genclass.dev/docs/dashboard), and without the script every section shows. With a plan file that left the dashboard out, the locked page (sign in, add the app to the account, see the plans; `src/locked.ts`). 404 page for an unknown secret; `GET /dashboard` redirects to `/start`. `/dashboard/sample` stays open | diff --git a/telemetry-worker/src/dashboard.ts b/telemetry-worker/src/dashboard.ts index a0800440..60adb753 100644 --- a/telemetry-worker/src/dashboard.ts +++ b/telemetry-worker/src/dashboard.ts @@ -16,6 +16,9 @@ // page views, engaged time, scroll milestones, and clicks, rage clicks and dead clicks per page and element key, as // ix_* counters with their own per-batch budget (MAX_IX_COUNTERS), so they never crowd out the diagnostics counters. // The runtime sends deltas, so they are plain counters like routes: a repeated batch is skipped as a whole. +// Navigation paths (ANALYTICS.md section 11): entries, route-to-route steps with the time between, how each view was +// reached, the first element interacted with per page, exits and the first 5 routes of each page load's path, all +// as counters per route or element key; no per-visitor sequence is stored outside the batch in R2. // - Retention (daily cron): daily counters and recent rows 90 days, hourly counters 48 hours, summary state 2 days. export const TOKEN_RE = /^gc_[A-Za-z0-9]{22}$/; @@ -343,11 +346,21 @@ const IX_FIELDS: [string, string][] = [ ["dead", "ix_dead"], ]; const chars = (s: string, n: number): string => [...s].slice(0, n).join(""); +const IX_NAV = new Set(["load", "reload", "back_forward", "push", "back", "forward", "pop"]); +/** Routes of a path the collector keeps, and the key's length. */ +const IX_PATH_STEPS = 5; +const IX_PATH_MAX = 240; +const ixRoute = (v: unknown): string | undefined => { + const r = str(v); + return r ? chars(r, 100) : undefined; +}; /** * One `interactions` event into ix_* counters (key "" is the total; the route, or route TAB element key, the * breakdown): ix_views (a view's first event), ix_engaged_ms, ix_scroll (route TAB milestone), ix_click, ix_rage, - * ix_dead. Element keys are selectors and names from the page's markup, never its text (the runtime builds them). + * ix_dead; and the navigation counters of ANALYTICS.md 11.2: ix_entry, ix_nav and ix_nav_ms (from TAB to), ix_arrive + * (nav, and route TAB nav), ix_first (route TAB element key), ix_first_n and ix_first_ms (route), ix_exit, ix_path. + * Element keys are selectors and names from the page's markup, never its text (the runtime builds them). */ function interactions(ix: Counters, e: Ev): void { const route = chars(str(e.route) ?? "(unknown)", 100); @@ -361,6 +374,37 @@ function interactions(ix: Counters, e: Ev): void { ix.add("ix_engaged_ms", route, ms); } if (Array.isArray(e.scroll)) for (const m of new Set(e.scroll)) if (IX_MILESTONES.has(m as number)) ix.add("ix_scroll", `${route}\t${m}`); + if (e.start === true) { + const nav = typeof e.nav === "string" ? (IX_NAV.has(e.nav) ? e.nav : "(other)") : undefined; + if (nav) { + ix.add("ix_arrive", nav); + ix.add("ix_arrive", `${route}\t${nav}`); + } + const from = ixRoute(e.from); + if (from) { + ix.add("ix_nav", `${from}\t${route}`); + const gap = num(e.fromMs); + if (gap !== undefined && gap >= 0 && gap <= IX_MAX_MS) ix.add("ix_nav_ms", `${from}\t${route}`, gap); + } else { + ix.add("ix_entry"); + ix.add("ix_entry", route); + } + } + const first = str(e.first); + if (first && first.trim()) { + ix.add("ix_first", `${route}\t${chars(first.trim(), KEY_MAX)}`); + ix.add("ix_first_n", route); + const ms = num(e.firstMs); + if (ms !== undefined && ms >= 0 && ms <= IX_MAX_MS) ix.add("ix_first_ms", route, ms); + } + if (e.exit === true) { + ix.add("ix_exit"); + ix.add("ix_exit", route); + if (Array.isArray(e.path)) { + const steps = e.path.slice(0, IX_PATH_STEPS).map(ixRoute); + if (steps.length && steps.every((x) => x)) ix.add("ix_path", chars(steps.join(" > ") + (e.path.length > IX_PATH_STEPS ? " > …" : ""), IX_PATH_MAX)); + } + } for (const [field, metric] of IX_FIELDS) { for (const [k, v] of Object.entries(obj(e[field])).slice(0, IX_KEYS)) { const n = num(v); diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts index 5f318856..00e5841a 100644 --- a/telemetry-worker/src/pages.ts +++ b/telemetry-worker/src/pages.ts @@ -987,6 +987,75 @@ const DASH_JS = ` .sort(function(a, b){ return b.count - a.count; }); ixTable(document.getElementById("ixflags"), flags, "Clicks", "No rage or dead clicks in this period."); document.getElementById("c-visits").textContent = fmt(views); + moves(S); + } + // navigation paths (ANALYTICS.md section 11): entries, steps, how views were reached, first interactions, exits, paths + var NAVS = { load: "page load", reload: "reload", back_forward: "browser back/forward (page load)", push: "in-app link", back: "in-app back", forward: "in-app forward", pop: "in-app back or forward" }; + function moves(S){ + var B = S.breakdowns, T0 = S.totals, views = T0.ix_views || 0, entries = T0.ix_entry || 0, exits = T0.ix_exit || 0; + var arrive = (B.ix_arrive || []).filter(function(x){ return x.key.indexOf("\\t") < 0; }); + var on = entries > 0 || arrive.length > 0 || (B.ix_nav || []).length > 0; + var sec = document.getElementById("moves"); if (sec) sec.classList.toggle("hidden", !on); + if (!on) return; + var am = map(arrive), hist = (am.back || 0) + (am.forward || 0) + (am.pop || 0) + (am.back_forward || 0); + var dl = el("dl", "health"); + row(dl, "Entries", fmt(entries), "page views that started a page load"); + row(dl, "Steps between pages", fmt(sum(B.ix_nav || [])), entries ? (Math.round(10 * sum(B.ix_nav || []) / entries) / 10) + " per page load" : ""); + row(dl, "Back and forward", views ? share(hist, views) + " of views" : "-", arrive.length ? arrive.slice().sort(function(a, b){ return b.count - a.count; }).slice(0, 4).map(function(x){ return (NAVS[x.key] || x.key) + " " + share(x.count, views); }).join(", ") : ""); + row(dl, "Exits", fmt(exits), entries ? share(Math.min(exits, entries), entries) + " of page loads sent their end (a closed or discarded background tab may not)" : ""); + var mh = document.getElementById("ixmoves"); mh.textContent = ""; mh.appendChild(dl); + mh.appendChild(el("figcaption", null, "A full page load to another page of this app ends one path and starts another: page loads are never linked, so on a site without client-side routing each page view is a path of one.")); + var ph = document.getElementById("ixpaths"); ph.textContent = ""; + var paths = (B.ix_path || []).slice().sort(function(a, b){ return b.count - a.count; }); + if (paths.length) { + var tb = el("table", "dt"), hd = el("thead"), hr = el("tr"); + ["Path (first 5 pages)", "Page loads", ""].forEach(function(h, i){ hr.appendChild(el("th", i === 1 ? "num" : null, h)); }); + hd.appendChild(hr); tb.appendChild(hd); var body = el("tbody"), max = paths[0].count || 1; + paths.slice(0, 10).forEach(function(x){ + var tr = el("tr"), pc = el("td", "route", x.key); pc.title = x.key; tr.appendChild(pc); + tr.appendChild(el("td", "num", fmt(x.count))); + var td = el("td", "wide"), mix = el("div", "mix"), i = el("i"); i.style.width = (100 * x.count / max) + "%"; i.style.background = "var(--accent-f)"; mix.appendChild(i); td.appendChild(mix); tr.appendChild(td); + body.appendChild(tr); + }); + tb.appendChild(body); ph.appendChild(tb); + ph.appendChild(el("figcaption", null, "Table 7. The most taken paths: the routes of one page load in order, counted when the page load ended.")); + } + var sh = document.getElementById("ixsteps"); sh.textContent = ""; + var gaps = map(B.ix_nav_ms), steps = pairs(B.ix_nav).sort(function(a, b){ return b.count - a.count; }); + if (steps.length) { + var tb2 = el("table", "dt"), hd2 = el("thead"), hr2 = el("tr"); + ["From", "To", "Steps", "Time before the step"].forEach(function(h, i){ hr2.appendChild(el("th", i > 1 ? "num" : null, h)); }); + hd2.appendChild(hr2); tb2.appendChild(hd2); var body2 = el("tbody"); + steps.slice(0, 12).forEach(function(x){ + var tr = el("tr"), a = el("td", "route", x.route), b = el("td", "route", x.key); a.title = x.route; b.title = x.key; tr.appendChild(a); tr.appendChild(b); + tr.appendChild(el("td", "num", fmt(x.count))); + tr.appendChild(el("td", "num", x.count ? dur((gaps[x.route + "\\t" + x.key] || 0) / x.count) : "-")); + body2.appendChild(tr); + }); + tb2.appendChild(body2); sh.appendChild(tb2); + sh.appendChild(el("figcaption", null, "Table 8. Page to page: in-app steps between routes, with the average time from the first page's start to the step.")); + } + var xh = document.getElementById("ixexits"); xh.textContent = ""; + var vm = map(B.ix_views), em = map(B.ix_entry), xm = map(B.ix_exit), fn = map(B.ix_first_n), fms = map(B.ix_first_ms), back = {}, firsts = {}; + pairs(B.ix_arrive).forEach(function(x){ if (x.key === "back" || x.key === "forward" || x.key === "pop" || x.key === "back_forward") back[x.route] = (back[x.route] || 0) + x.count; }); + pairs(B.ix_first).forEach(function(x){ if (!firsts[x.route] || firsts[x.route].count < x.count) firsts[x.route] = x; }); + var list = (B.ix_views || []).slice().sort(function(a, b){ return b.count - a.count; }); + if (!list.length) return; + var tb3 = el("table", "dt"), hd3 = el("thead"), hr3 = el("tr"); + ["Page", "Entries", "Exits", "Exit rate", "Back/forward", "First interaction", "Time to it"].forEach(function(h, i){ hr3.appendChild(el("th", i && i !== 5 ? "num" : null, h)); }); + hd3.appendChild(hr3); tb3.appendChild(hd3); var body3 = el("tbody"); + list.slice(0, 12).forEach(function(x){ + var tr = el("tr"), rc = el("td", "route", x.key); rc.title = x.key; tr.appendChild(rc); + tr.appendChild(el("td", "num", fmt(em[x.key] || 0))); + tr.appendChild(el("td", "num", fmt(xm[x.key] || 0))); + tr.appendChild(el("td", "num", share(Math.min(xm[x.key] || 0, x.count), x.count))); + tr.appendChild(el("td", "num", share(Math.min(back[x.key] || 0, x.count), x.count))); + var f = firsts[x.key], fc = el("td", "route", f ? f.key : "-"); if (f) fc.title = f.key + " (" + fmt(f.count) + " views)"; tr.appendChild(fc); + tr.appendChild(el("td", "num", fn[x.key] ? dur((fms[x.key] || 0) / fn[x.key]) : "-")); + body3.appendChild(tr); + }); + tb3.appendChild(body3); xh.appendChild(tb3); + xh.appendChild(el("figcaption", null, "Table 9. Per page: where page loads started and ended (exit rate: exits over views), arrivals by back or forward, and the element most often interacted with first (a click, or a focus on a form field after a key press) with the average time to the first interaction.")); } // ---------- the closed loop (Fig. 3): this app's problems, what the agent does with each, the shared fix, prove, ship @@ -1451,6 +1520,13 @@ versions ignore it. ${LOCAL_HOSTS} Your deployed site sends by default.

Most clicked

Table 5. Elements by clicks

Rage and dead clicks

Table 6. Where clicks went nowhere
+
All breakdowns

Closed-loop development

Fig. 3. What broke on this app, and what your agent does with each
@@ -1462,8 +1538,8 @@ versions ignore it. ${LOCAL_HOSTS} Your deployed site sends by default.

This page shows counts only: the kind of problem, the page route, confidence, what GenClass did and how fast it -decided, and, for apps that turn on interaction analytics, page views, time on page, scroll depth and clicks per element (named by a -selector or a name the app gave it, never its text). No text from your pages, no IP addresses and no user agents are stored. Data is kept ${(opts.keepDays ?? 90) >= 365 ? "1 year" : `${opts.keepDays ?? 90} days`} (hourly detail 48 hours). ${privacy} +decided, and, for apps that turn on interaction analytics, page views, the path between pages, time on page, scroll depth and clicks +per element (named by a selector or a name the app gave it, never its text). No text from your pages, no IP addresses and no user agents are stored. Data is kept ${(opts.keepDays ?? 90) >= 365 ? "1 year" : `${opts.keepDays ?? 90} days`} (hourly detail 48 hours). ${privacy} `; return shell( diff --git a/telemetry-worker/src/sample.ts b/telemetry-worker/src/sample.ts index 0d411b2d..1216707e 100644 --- a/telemetry-worker/src/sample.ts +++ b/telemetry-worker/src/sample.ts @@ -49,6 +49,33 @@ const IX_PAGES: [string, number, number, number[]][] = [ ["/account", 0.03, 35, [0.9, 0.71, 0.5, 0.42]], ["/orders", 0.02, 29, [0.93, 0.66, 0.45, 0.37]], ]; +/** Navigation (simulated, ANALYTICS.md section 11): steps per 100 page views with seconds before the step, paths per 100 page loads, first interactions. */ +const IX_STEPS: [string, string, number, number][] = [ + ["/", "/products", 14, 31], + ["/products", "/products/:id", 12, 44], + ["/products/:id", "/cart", 5, 63], + ["/", "/search", 6, 18], + ["/search", "/products/:id", 5, 27], + ["/cart", "/checkout", 4, 41], + ["/products/:id", "/products", 4, 52], + ["/checkout", "/orders", 1.5, 96], +]; +const IX_PATHS: [string, number][] = [ + ["/", 34], + ["/ > /products > /products/:id", 9], + ["/products/:id", 8], + ["/ > /search > /products/:id", 5], + ["/ > /products > /products/:id > /cart > /checkout", 3], + ["/products/:id > /cart > /checkout > /orders", 1.5], +]; +const IX_FIRST: [string, string, number][] = [ + ["/", 'a[href="/products"]', 0.41], + ["/products", '[data-genclass-name="filter-size"]', 0.38], + ["/products/:id", '[data-genclass-name="add-to-cart"]', 0.36], + ["/search", "input#q", 0.71], + ["/cart", "button.qty-plus", 0.33], + ["/checkout", "input#email", 0.68], +]; /** Clicks per 100 page views (simulated), and rage and dead clicks per 1,000, per page and element key. */ const IX_CLICKS: [string, string, number][] = [ ["/", 'a[href="/products"]', 21], @@ -154,6 +181,14 @@ export function sampleStats(rangeParam: string | null, now: Date): Stats { const per = (list: [string, string, number][], unit: number) => list.map(([route, k, rate]) => ({ key: `${route}\t${k}`, count: Math.max(1, Math.round((views * rate) / unit)) })); const ixClick = per(IX_CLICKS, 100), ixRage = per(IX_RAGE, 1000), ixDead = per(IX_DEAD, 1000); const total = (l: { count: number }[]) => l.reduce((a, x) => a + x.count, 0); + const steps = IX_STEPS.map(([a, b, rate, secs]) => ({ key: `${a}\t${b}`, count: Math.round((views * rate) / 100), secs })); + const entries = Math.max(1, views - total(steps)); + const viewsOf = (r0: string) => ixViews.find((p) => p.route === r0)?.n ?? 0; + const entryOf = (r0: string) => Math.max(0, viewsOf(r0) - steps.filter((x) => x.key.endsWith(`\t${r0}`)).reduce((a, x) => a + x.count, 0)); + const exitOf = (r0: string) => Math.round(Math.max(0, viewsOf(r0) - steps.filter((x) => x.key.startsWith(`${r0}\t`)).reduce((a, x) => a + x.count, 0)) * 0.86); + const ixEntry = ixViews.map((p) => ({ key: p.route, count: entryOf(p.route) })); + const ixExit = ixViews.map((p) => ({ key: p.route, count: exitOf(p.route) })); + const ixFirst = IX_FIRST.map(([r0, k, rate]) => ({ key: `${r0}\t${k}`, count: Math.round(viewsOf(r0) * rate) })); const coalesce = Math.round((tot.duplicate ?? 0) * 0.55), discard = Math.round((tot.stale ?? 0) * 0.4); const interventions = coalesce + discard; const undone = interventions > 6 ? 1 : 0; @@ -199,6 +234,18 @@ export function sampleStats(rangeParam: string | null, now: Date): Stats { ix_click: ixClick.sort((a, b) => b.count - a.count), ix_rage: ixRage.sort((a, b) => b.count - a.count), ix_dead: ixDead.sort((a, b) => b.count - a.count), + ix_entry: ixEntry.sort((a, b) => b.count - a.count), + ix_exit: ixExit.sort((a, b) => b.count - a.count), + ix_nav: steps.map(({ key, count }) => ({ key, count })).sort((a, b) => b.count - a.count), + ix_nav_ms: steps.map(({ key, count, secs }) => ({ key, count: count * secs * 1000 })), + ix_arrive: [ + ...[["load", 0.78], ["push", 0.15], ["back", 0.04], ["back_forward", 0.02], ["reload", 0.01]].map(([k, f]) => ({ key: k as string, count: Math.round(views * (f as number)) })), + ...ixViews.map((p) => ({ key: `${p.route}\tback`, count: Math.round(p.n * 0.04) })), + ], + ix_first: ixFirst.sort((a, b) => b.count - a.count), + ix_first_n: IX_FIRST.map(([r0]) => ({ key: r0, count: Math.round(viewsOf(r0) * 0.8) })), + ix_first_ms: IX_FIRST.map(([r0], i) => ({ key: r0, count: Math.round(viewsOf(r0) * 0.8) * (3200 + 1700 * i) })), + ix_path: IX_PATHS.map(([key, rate]) => ({ key, count: Math.round((entries * rate) / 100) })), }; return { name: SAMPLE_PROJECT.name, @@ -213,6 +260,7 @@ export function sampleStats(rangeParam: string | null, now: Date): Stats { model_ready: ready, model_load_failed: failed, modelLoadAvgMs: 1340, latency_ms_n: Math.round(sessions * 0.66), latencyAvgMs: 168, failOpens: Math.round(sessions * 0.011), modelErrors: failed, detectionsWithPage: detections, possible, possibleWithPage: possible, hidden_skipped: Math.round(sessions * 0.05), model_dropped: hourly ? 0 : Math.round(sessions * 0.002), telemetry_dropped: 0, ix_views: views, ix_engaged_ms: engagedMs, ix_click: total(ixClick), ix_rage: total(ixRage), ix_dead: total(ixDead), + ix_entry: total(ixEntry), ix_exit: total(ixExit), }, breakdowns, distinct: Object.fromEntries(Object.entries(breakdowns).map(([k, v]) => [k, v.length])), diff --git a/telemetry-worker/test/dashboard.test.ts b/telemetry-worker/test/dashboard.test.ts index 09800672..8197da97 100644 --- a/telemetry-worker/test/dashboard.test.ts +++ b/telemetry-worker/test/dashboard.test.ts @@ -853,10 +853,37 @@ describe("interaction analytics: runtime `interactions` events (docs/runtime/ANA ); }); + it("navigation paths (ANALYTICS.md section 11): entries, steps with time, arrivals, first interactions, exits, paths", async () => { + const { sql } = setup(); + const events = [ + sess(), + ixe(1, { route: "/", start: true, nav: "load", first: 'a[href="/pricing"]', firstMs: 2_000 }), + ixe(2, { route: "/", end: true }), + ixe(3, { view: 2, start: true, nav: "push", from: "/", fromMs: 4_000, first: "button.buy", firstMs: 1_500 }), + ixe(4, { view: 3, route: "/", start: true, nav: "back", from: "/pricing", fromMs: 9_000, exit: true, end: true, path: ["/", "/pricing", "/"] }), + // another page load: one view, an unknown nav value, a long path, bad times + ixe(5, { view: 1, route: "/docs", start: true, nav: "teleport", first: " ", firstMs: -1, exit: true, path: ["/docs", "/a", "/b", "/c", "/d", "/e", "/f"] }), + ixe(6, { view: 2, route: "/a", start: true, nav: "push", from: "/docs", fromMs: 1e12 }), + ]; + await aggregate(sql.api, TOK, batchOf(events), NOW); + const s = await statsOf(sql); + expect(s.totals).toMatchObject({ ix_views: 5, ix_entry: 2, ix_exit: 2 }); + expect(bdOf(s, "ix_entry")).toEqual({ "/": 1, "/docs": 1 }); + expect(bdOf(s, "ix_nav")).toEqual({ "/\t/pricing": 1, "/pricing\t/": 1, "/docs\t/a": 1 }); + expect(bdOf(s, "ix_nav_ms")).toEqual({ "/\t/pricing": 4_000, "/pricing\t/": 9_000 }); + expect(bdOf(s, "ix_arrive")).toEqual({ load: 1, push: 2, back: 1, "(other)": 1, "/\tload": 1, "/pricing\tpush": 1, "/\tback": 1, "/docs\t(other)": 1, "/a\tpush": 1 }); + expect(bdOf(s, "ix_first")).toEqual({ '/\ta[href="/pricing"]': 1, "/pricing\tbutton.buy": 1 }); + expect(bdOf(s, "ix_first_n")).toEqual({ "/": 1, "/pricing": 1 }); + expect(bdOf(s, "ix_first_ms")).toEqual({ "/": 2_000, "/pricing": 1_500 }); + expect(bdOf(s, "ix_exit")).toEqual({ "/": 1, "/docs": 1 }); + expect(bdOf(s, "ix_path")).toEqual({ "/ > /pricing > /": 1, "/docs > /a > /b > /c > /d > …": 1 }); + }); + it("the sample dashboard shows simulated interaction analytics too", () => { const st = sampleStats("7d", NOW); expect(st.totals.ix_views).toBeGreaterThan(0); expect(st.breakdowns.ix_click!.length).toBeGreaterThan(0); expect(st.series.every((b) => b.ix_views > 0)).toBe(true); + for (const m of ["ix_entry", "ix_exit", "ix_nav", "ix_nav_ms", "ix_arrive", "ix_first", "ix_path"]) expect(st.breakdowns[m]!.length, m).toBeGreaterThan(0); }); }); diff --git a/telemetry-worker/test/pages.test.ts b/telemetry-worker/test/pages.test.ts index 7ccf4d28..49523705 100644 --- a/telemetry-worker/test/pages.test.ts +++ b/telemetry-worker/test/pages.test.ts @@ -452,6 +452,32 @@ describe("dashboard page: interaction analytics (docs/runtime/ANALYTICS.md secti await close(); }); + it("how visitors move (ANALYTICS.md section 11): entries, steps, back and forward, exits; paths, page to page, per page", async () => { + const st = sampleStats("7d", NOW); + const { doc, text, hidden, heads, close } = await render(st); + expect(hidden("moves")).toBe(false); + for (const t of ["Entries", "Steps between pages", "Back and forward", "Exits", "page loads are never linked"]) expect(text("#ixmoves")).toContain(t); + expect(heads("#ixpaths")).toEqual(["Path (first 5 pages)", "Page loads", ""]); + expect(text("#ixpaths")).toContain("/ > /products > /products/:id"); + expect(heads("#ixsteps")).toEqual(["From", "To", "Steps", "Time before the step"]); + expect(doc.querySelectorAll("#ixsteps tbody tr").length).toBe(Math.min(12, st.breakdowns.ix_nav!.length)); + expect(heads("#ixexits")).toEqual(["Page", "Entries", "Exits", "Exit rate", "Back/forward", "First interaction", "Time to it"]); + expect(text("#ixexits")).toContain('[data-genclass-name="add-to-cart"]'); + expect(doc.querySelector(".dfoot")!.textContent).toContain("the path between pages"); + await close(); + }); + + it("the navigation section stays hidden for a runtime that sends no navigation fields", async () => { + const st = sampleStats("7d", NOW); + for (const k of ["ix_entry", "ix_exit", "ix_nav", "ix_nav_ms", "ix_arrive", "ix_first", "ix_first_n", "ix_first_ms", "ix_path"]) delete st.breakdowns[k]; + delete st.totals.ix_entry; + delete st.totals.ix_exit; + const { hidden, close } = await render(st); + expect(hidden("visits")).toBe(false); + expect(hidden("moves")).toBe(true); + await close(); + }); + it("hidden for an app that has not turned analytics on; the footer says what interaction data is", async () => { const st = sampleStats("7d", NOW); for (const k of Object.keys(st.breakdowns)) if (k.startsWith("ix_")) delete st.breakdowns[k]; @@ -460,6 +486,7 @@ describe("dashboard page: interaction analytics (docs/runtime/ANALYTICS.md secti expect(hidden("visits")).toBe(true); expect(hidden("clicks")).toBe(true); expect(hidden("nav-visits")).toBe(true); + expect(hidden("moves")).toBe(true); expect(doc.querySelector(".dfoot")!.textContent).toContain("never its text"); expect(doc.querySelector(".dfoot")!.textContent).toContain("No text from your pages, no IP addresses and no user agents are stored."); await close(); From 6c4915c0669f1e58f03b92ca3751e9862c08353a Mon Sep 17 00:00:00 2001 From: Karan Date: Sun, 25 Oct 2026 01:56:23 -0400 Subject: [PATCH 2/3] text-warped: read line boxes and transforms once per pass, describe only findings; RESULTS section 21 Co-Authored-By: Claude Opus 5.5 --- docs/runtime/RESULTS.md | 73 ++++++++++++++++++++ packages/runtime/CHANGELOG.md | 2 +- packages/runtime/src/visual/checks/warped.ts | 57 ++++++++++----- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/docs/runtime/RESULTS.md b/docs/runtime/RESULTS.md index e34af822..44ab8d4c 100644 --- a/docs/runtime/RESULTS.md +++ b/docs/runtime/RESULTS.md @@ -1056,3 +1056,76 @@ Caveats: the DOM oracle is the harness's (secondary, the same for both batches), reads; for double-save it leaves out the notifications and blanks the digits of the title the scenario makes unique. The two small apps were written by the author of the rules they measure (a mechanism check on the libraries' documented patterns, not an independent recall figure). One Strapi, one seed, one machine; seeds 1 to 6 only. + +## 21. Measured visual checks `ad-heavy` and `text-warped` (development Mac, Chromium 153 through Playwright 1.63.0, 2026-10-25, branch `strange-sites`) + +Measured checks, not model results (VISUAL.md sections 3.24 and 3.25). Run on a 16 GB development Mac, not on GitHub +runners, so the cost figures are this machine's and only the comparison with main's engine on the same machine, +the same day, means anything. The faults and the fixtures were written by the author of the checks, so the recall +below is a mechanism check (each planted fault is found, nothing else is), not an independent recall figure. + +**Fixtures** (`test/browser/visual`, Chromium; the whole suite, 49 tests, passed): + +| fixture | planted | found | other findings | +|---|---|---|---| +| `ads.html` (ad slots and ad-sized cross-site frames over 30% of the page) | 1 `ad-heavy` (density) | 1 | 0 | +| `ads-interstitial.html` (a full-screen ad with a close button) | 1 `ad-heavy` (interstitial, high) | 1 | 0 | +| `warped.html` (10 faults across the 7 reasons, 5 clean controls next to them) | 10 `text-warped` | 10, each with its reason | 0 | +| `clean-strange.html` (two ad slots, video and map embeds, a sticky header, a cookie banner, an ellipsis, a line clamp, a masked "read more", a flip card, a carousel track, a rotated axis label, a 10° slant, tracking at 0.2 em and a logo at 0.6 em, a tight display heading, ruby, emoji, right-to-left text, vertical text, a backdrop word, a quarter-scale preview with `pointer-events: none`, a floating label), desktop and phone | 0 | 0 | 0 | +| the 44 existing fixture tests (their exact finding sets) | unchanged | unchanged | 0 new | + +The first browser run had one false report on two clean elements (`overlap` on text with `text-overflow: ellipsis`, +VISUAL.md section 19); after the change, none. + +**Demos** (`bench/visual`, group `demos`: the six demos and their index, desktop 1280 x 800 and phone 390 x 844, light +and dark, scripted hover, Tab and scrolling; faults `T1` and `ad-heavy` only, `--faults T1,ad-heavy`): + +and the four starter templates the same way (`npm run dev`, prepared with `corpus.mjs prepare templates`): + +| clean pages, every kind | page views | findings | false reports | per page view | fixes applied | +|---|---|---|---|---|---| +| demos, 7 pages | 28 | 0 | 0 | 0 | 0 | +| templates, 4 | 16 | 0 | 0 | 0 | 0 | + +| kind | group | planted | took | found | recall | desktop | phone | +|---|---|---|---|---|---|---|---| +| `text-warped` (7 reasons per load) | demos | 98 | 95 | 95 | 1.00 | 48 / 48 | 47 / 47 | +| `text-warped` | templates | 56 | 15 | 15 | 1.00 | 7 / 7 | 8 / 8 | +| `ad-heavy` | demos | 14 | 14 | 14 | 1.00 | 7 / 7 | 7 / 7 | +| `ad-heavy` | templates | 8 | 8 | 8 | 1.00 | 4 / 4 | 4 / 4 | + +The templates are small pages: after the first two text faults no free text element was left in view for the others +(40 not applicable), so their `text-warped` figure covers mostly `overlap` and `clipped`. The demos' fault loads ran +twice, before and after the cost change below, with the same result. + +Not applicable (left out of the denominator): on the index page at phone width no text element in view had two words +for `spacing` or was free for `tiny`; on the status page the planted half-emoji left the viewport; on one template the +clipped line did not take. Every `ad-heavy` +load also reported `layout-shift` on the content below the inserted slots: the harness inserts them after load, so +the shift is real, and it is the only finding outside the planted faults. No fix was applied anywhere (neither check +has one). + +**Cost on the demos, the same machine, main's engine (`10f0c1d`) against this branch** (7 loads at 4x CPU throttling, +28 at 1x; the check time of the automatic first pass, the longest slice, the session's check time): + +| | first pass p50 / p95 | first pass inside the 100 ms budget | longest slice p95 / max | session p50 / p95 | +|---|---|---|---|---| +| 1x, main | 15.1 / 26.3 ms | 100% | 9.6 / 9.6 ms | 39.1 / 62.4 ms | +| 1x, this branch | 21.4 / 36.3 ms | 100% | 10.5 / 10.9 ms | 47.9 / 75.4 ms | +| 4x, main | 70.5 / 87.2 ms | 100% | 15.3 / 15.3 ms | 94.8 / 127.8 ms | +| 4x, this branch | 78.8 / 112.3 ms | 86% (6 of 7) | 13.9 / 13.9 ms | 106.3 / 162.8 ms | + +The first measurement of this branch was slower (4x first pass p50 98.1 ms); reading each element's line boxes and +transforms once per pass and building element descriptions only for findings brought it to the figures above (on the +10,000-element fixture `big.html` the session's check time went from 136 to 48 ms, longest slice 8.1 ms). Long +tasks during the 4x loads come from the throttled pages themselves (main: 6 of 7 loads, up to 258 ms; this branch: 6 +of 7, up to 160 ms); the checks' own slices stay under 16 ms. + +**First load** (`test/first-load-size.mjs` against main's dist built from `10f0c1d`): `/auto` 108.324 KB against +108.320 KB, the main entry 100.227 KB against 100.225 KB. The first-load code is byte-identical (checked by diffing the +two bundles); the 4 B and 2 B are the content-hashed file names of the two lazy chunks it imports. The lazy chunks grow: +`dist/visual/index.js` 40.38 to 47.06 KB gzip (+6.69 KB), `dist/analytics/index.js` 3.69 to 4.26 KB gzip (+0.57 KB), each loaded only where its +feature is on. + +**Not run:** the compat apps and the public sites for these two kinds (the bench workflow, `visual-measure.yml`, which +loads about 40 public sites and is the owner's to start); Firefox and WebKit. diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 020256c6..136e895b 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -12,7 +12,7 @@ 0.5 em or more (or -0.2 em and less), and text drawn below 8 px that is still meant to be read. Both are kind `"check"`, report only (no runtime fix), run in the lazy chunk's idle slices on the full tier, and never feed the model; `CheckKind` gains both. First load unchanged (0 B of first-load code; the lazy visual chunk grows by about - 6.5 KB gzip). + 6.7 KB gzip). - **Interaction analytics: navigation paths** ([docs/runtime/ANALYTICS.md](../../docs/runtime/ANALYTICS.md) section 11, TELEMETRY.md "Interaction analytics"). With `analytics: true` (still off by default, still only with the app's token), `interactions` events also say how each page view was reached (`nav`: load, reload, back_forward, push, back, diff --git a/packages/runtime/src/visual/checks/warped.ts b/packages/runtime/src/visual/checks/warped.ts index 4c5dbc3a..52df5daf 100644 --- a/packages/runtime/src/visual/checks/warped.ts +++ b/packages/runtime/src/visual/checks/warped.ts @@ -126,8 +126,22 @@ export function chainVerdict(m: Linear): ChainVerdict { }; } -/** The transform product from the element up to the root, and the element in it that does the most. */ -export function transformChain(r: Reader, el: Element): { m: Linear; culprit: Element | null; backface: boolean } | null { +type Chain = { m: Linear; culprit: Element | null; backface: boolean } | null; +/** Per pass (a Reader lives for one): each element's transform chain and line boxes, read once. */ +const chains = new WeakMap>(); +const boxesOf = new WeakMap>(); + +/** The transform product from the element up to the root, and the element in it that does the most (cached per pass). */ +export function transformChain(r: Reader, el: Element): Chain { + let c = chains.get(r); + if (!c) chains.set(r, (c = new Map())); + if (c.has(el)) return c.get(el) ?? null; + const v = readChain(r, el); + c.set(el, v); + return v; +} + +function readChain(r: Reader, el: Element): Chain { let m: Linear = IDENTITY; let culprit: Element | null = null; let worst = 0; @@ -235,6 +249,14 @@ function firstText(el: Element): Text | null { } function lineBoxes(r: Reader, t: Text): Rect[] { + let c = boxesOf.get(r); + if (!c) boxesOf.set(r, (c = new Map())); + let v = c.get(t); + if (!v) c.set(t, (v = readBoxes(r, t))); + return v; +} + +function readBoxes(r: Reader, t: Text): Rect[] { try { const range = r.doc.createRange(); range.selectNodeContents(t); @@ -340,7 +362,8 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { const data = t.data; const s = r.cs(el); const fs = parseFloat(s.fontSize) || 0; - const who = named(ctx, el); + // the description is built only for a finding (it reads the element's text and name) + const who = () => named(ctx, el); const tofu = tofuChars(data); if (tofu.length) { @@ -349,7 +372,7 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { return { reason: "tofu", measured: { reason: "tofu", codePoints: tofu.join(" "), count: tofu.length }, - text: `${who} shows ${tofu.length === 1 ? "a character" : `${tofu.length} characters`} no font can draw (${tofu.join(", ")}: ${tofu.some((u) => /^U\+D[89A-F]/.test(u)) ? "half of a surrogate pair, a string cut through an emoji or another character outside the BMP" : "unassigned in Unicode"}); browsers draw them as boxes.`, + text: `${who()} shows ${tofu.length === 1 ? "a character" : `${tofu.length} characters`} no font can draw (${tofu.join(", ")}: ${tofu.some((u) => /^U\+D[89A-F]/.test(u)) ? "half of a surrogate pair, a string cut through an emoji or another character outside the BMP" : "unassigned in Unicode"}); browsers draw them as boxes.`, fixText: "cut strings by code point, never by UTF-16 index (Array.from(s).slice(0, n), or Intl.Segmenter for user-perceived characters), and never store or send half a surrogate pair", }; } @@ -358,24 +381,24 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { if (!chain) return null; const v = chainVerdict(chain.m); const by = chain.culprit && chain.culprit !== el ? chain.culprit : null; - const bySel = chain.culprit ? ctx.sel(chain.culprit) : ctx.sel(el); + const bySel = () => (chain.culprit ? ctx.sel(chain.culprit) : ctx.sel(el)); if (v.det < 0 && !chain.backface && lettersOf(data) >= 3) return { reason: "mirrored", on: by ?? undefined, - measured: { reason: "mirrored", determinant: two(v.det), transformOn: bySel }, - text: `${who} is mirrored by a transform${by ? ` on ${bySel}` : ""} (determinant ${two(v.det)}): the text reads backwards.`, + measured: { reason: "mirrored", determinant: two(v.det), transformOn: bySel() }, + text: `${who()} is mirrored by a transform${by ? ` on ${bySel()}` : ""} (determinant ${two(v.det)}): the text reads backwards.`, css: "transform: none;", }; if ((v.skew > SKEW_DEG || v.stretch >= STRETCH) && lettersOf(data) >= 3) return { reason: "skewed", on: by ?? undefined, - measured: { reason: "skewed", skewDeg: Math.round(v.skew), stretch: two(v.stretch), transformOn: bySel }, + measured: { reason: "skewed", skewDeg: Math.round(v.skew), stretch: two(v.stretch), transformOn: bySel() }, text: v.skew > SKEW_DEG - ? `${who} is skewed ${Math.round(v.skew)}° by a transform${by ? ` on ${bySel}` : ""} (above ${SKEW_DEG}°).` - : `${who} is stretched ${two(v.stretch)}x on one axis by a transform${by ? ` on ${bySel}` : ""} (${STRETCH}x or more).`, + ? `${who()} is skewed ${Math.round(v.skew)}° by a transform${by ? ` on ${bySel()}` : ""} (above ${SKEW_DEG}°).` + : `${who()} is stretched ${two(v.stretch)}x on one axis by a transform${by ? ` on ${bySel()}` : ""} (${STRETCH}x or more).`, css: "transform: none;", }; @@ -386,10 +409,10 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { measured: { reason: "spacing", letterSpacingEm: two(em(s.letterSpacing, fs)), wordSpacingEm: two(em(s.wordSpacing, fs)), fontPx: two(fs) }, text: sp === "wide" - ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who} spaces letters wider than a space (${WIDE_EM} em or more), so its words fall apart.` + ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who()} spaces letters wider than a space (${WIDE_EM} em or more), so its words fall apart.` : sp === "tight" - ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who} runs its letters into each other (${TIGHT_EM} em or less).` - : `word-spacing ${two(em(s.wordSpacing, fs))} em on ${who} removes the space between words (${TIGHT_EM} em or less).`, + ? `letter-spacing ${two(em(s.letterSpacing, fs))} em on ${who()} runs its letters into each other (${TIGHT_EM} em or less).` + : `word-spacing ${two(em(s.wordSpacing, fs))} em on ${who()} removes the space between words (${TIGHT_EM} em or less).`, css: sp === "words" ? "word-spacing: normal;" : "letter-spacing: 0.1em;", }; @@ -402,7 +425,7 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { reason: "tiny", on: v.scale < 0.999 && by ? by : undefined, measured: { reason: "tiny", renderedPx: two(rendered), fontPx: two(fs), scale: two(v.scale), lineBoxPx: two(h) }, - text: `${who} is drawn at ${two(rendered)} px${v.scale < 0.999 ? ` (${two(fs)} px text scaled by ${two(v.scale)}${by ? ` on ${bySel}` : ""})` : ""}, below ${TINY_PX} px, with a line box ${px(h)} tall.`, + text: `${who()} is drawn at ${two(rendered)} px${v.scale < 0.999 ? ` (${two(fs)} px text scaled by ${two(v.scale)}${by ? ` on ${bySel()}` : ""})` : ""}, below ${TINY_PX} px, with a line box ${px(h)} tall.`, css: v.scale < 0.999 ? "transform: none;" : "font-size: 12px;", }; } @@ -416,7 +439,7 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { return { reason: "overlap", measured: { reason: "overlap", overlap: two(o.f), lineBoxPx: two(Math.min(a.bottom - a.top, b.bottom - b.top)), lineHeight: s.lineHeight, fontPx: two(fs) }, - text: `lines of text overlap on ${who}: one line covers ${Math.round(o.f * 100)}% of the next one's height (line-height ${s.lineHeight} for ${px(fs)} text; threshold ${LINE_OVERLAP * 100}%).`, + text: `lines of text overlap on ${who()}: one line covers ${Math.round(o.f * 100)}% of the next one's height (line-height ${s.lineHeight} for ${px(fs)} text; threshold ${LINE_OVERLAP * 100}%).`, css: "line-height: 1.4;", }; } @@ -432,8 +455,8 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { measured: { reason: "clipped", axis: cut.axis, cutPx: Math.round(cut.px), ...(cut.visible !== undefined ? { visible: two(cut.visible) } : {}), clipOn: cs }, text: cut.axis === "x" - ? `${who} is cut mid-line: ${px(cut.px)} of its first line are hidden past the edge of ${cs} (overflow hidden, no ellipsis).` - : `${who} is cut through its first line: ${Math.round((cut.visible ?? 0) * 100)}% of the line's height shows inside ${cs} (overflow hidden).`, + ? `${who()} is cut mid-line: ${px(cut.px)} of its first line are hidden past the edge of ${cs} (overflow hidden, no ellipsis).` + : `${who()} is cut through its first line: ${Math.round((cut.visible ?? 0) * 100)}% of the line's height shows inside ${cs} (overflow hidden).`, css: cut.axis === "x" ? "text-overflow: ellipsis;" : "height: auto;", }; } From 6af7a867fbf24c47fc2bd17bf5b86dc3891995f2 Mon Sep 17 00:00:00 2001 From: Karan Date: Sun, 25 Oct 2026 03:12:03 -0400 Subject: [PATCH 3/3] text-warped: skip self-overlap under -webkit-line-clamp too (Linux Chromium gives the clamped line two nested boxes) Co-Authored-By: Claude Opus 5.5 --- docs/runtime/VISUAL.md | 7 ++++--- packages/runtime/src/visual/checks/warped.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/runtime/VISUAL.md b/docs/runtime/VISUAL.md index d76727b1..d46397f7 100644 --- a/docs/runtime/VISUAL.md +++ b/docs/runtime/VISUAL.md @@ -810,8 +810,8 @@ reason is a measurement of boxes (`Range.getClientRects()` line boxes, `getBound - `overlap`: two line boxes of the element's first text node overlap vertically by at least **50%** of the smaller one's height and horizontally by at least 8 px (lines on the same row never overlap horizontally; normal `line-height` from 1.0 leaves at most about 25% overlap of the font's content area). Not when the element or one of - its 3 nearest ancestors sets `text-overflow: ellipsis` (Chromium gives a truncated line two boxes, the whole run and - the part shown, one inside the other). Also between the first lines + its 3 nearest ancestors sets `text-overflow: ellipsis` or `-webkit-line-clamp` (Chromium gives a truncated line two + boxes, the whole run and the part shown, one inside the other). Also between the first lines of two different candidates, when at the centre of the overlap `elementsFromPoint` holds both, nothing between them paints a background, an image or a replaced element (the upper text does not hide the lower one), neither is `fixed` or `sticky` (a sticky header over scrolling text is normal), neither contains the other, both have opaque @@ -1638,7 +1638,8 @@ What the build does where sections 3.25 and 3.26 were not specific, and what the - **The ellipsis case.** The first browser run reported `overlap` on two correct elements with `text-overflow: ellipsis`: Chromium returns two line boxes for a truncated line (the whole run and the part shown, the second inside the first), which also describes three lines at `line-height: 0`. Self-overlap is now skipped under an ellipsis - (section 3.26); a `line-height: 0` paragraph is still found. + (section 3.26); a `line-height: 0` paragraph is still found. CI's Chromium on Linux then did the same for the last + line of a `-webkit-line-clamp` box (the development Mac's did not), so a line clamp is skipped the same way. - **Recall and false reports** are in RESULTS.md section 22 (the Chromium fixtures, and the demos and templates bench run on a development machine). The compat apps and the public sites were not run for these two kinds yet: they need the bench workflow (section 15), which loads public sites and is the owner's to start. diff --git a/packages/runtime/src/visual/checks/warped.ts b/packages/runtime/src/visual/checks/warped.ts index ad98249d..a145b157 100644 --- a/packages/runtime/src/visual/checks/warped.ts +++ b/packages/runtime/src/visual/checks/warped.ts @@ -273,10 +273,16 @@ function readBoxes(r: Reader, t: Text): Rect[] { const lettersOf = (s: string): number => (s.match(/\p{L}/gu) ?? []).length; const wordsOf = (s: string): number => (s.match(/\p{L}{2,}/gu) ?? []).length; -/** text-overflow: ellipsis on the element or one of its 3 nearest ancestors (the block container that truncates). */ +/** + * text-overflow: ellipsis or -webkit-line-clamp on the element or one of its 3 nearest ancestors (the block container + * that truncates): Chromium gives the truncated line two boxes, one inside the other. + */ function ellipsis(r: Reader, el: Element): boolean { let n = 0; - for (let x: Element | null = el; x && n <= 3; x = parentOf(x), n++) if (r.cs(x).textOverflow === "ellipsis") return true; + for (let x: Element | null = el; x && n <= 3; x = parentOf(x), n++) { + const s = r.cs(x) as CSSStyleDeclaration & { webkitLineClamp?: string }; + if (s.textOverflow === "ellipsis" || (s.webkitLineClamp && s.webkitLineClamp !== "none")) return true; + } return false; } @@ -431,7 +437,7 @@ export function readWarped(ctx: Ctx, r: Reader, el: Element): WarpRead | null { } if (!lines.length) return null; - // an ellipsis gives a truncated line two boxes (the whole run and the part shown), one inside the other + // an ellipsis or a line clamp gives a truncated line two boxes (the whole run and the part shown), one inside the other const o = ellipsis(r, el) ? { f: 0, i: -1, j: -1 } : selfOverlap(lines); if (o.f >= LINE_OVERLAP) { const a = lines[o.i];