Skip to content
7 changes: 5 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ object; version 0.2.1 sent from those hosts by default, by mistake, fixed in 0.2
do not count unique visitors. We store only a one-way hash of the visit ID, and delete visit records 90 days after
the visit started. We use this data only to show it on that website's dashboard (including funnels the website's
owner sets up there: a few pages in order and how many visits reached each) and in its export, never to train or
evaluate the model.
evaluate the model. Also, 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 the visitor first clicked or (with the keyboard) first moved to on each page and how long
that took, and, when the page load ends, its pages in order (at most 10) and that it ended there.

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. Only when the website turned on interaction analytics does
Expand All @@ -71,7 +74,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, visits per day, pages per visit, where visits
page views, the paths between pages, time on page, scroll depth and clicks per page and element, visits per day, pages per visit, where visits
came from, devices, countries, new or returning, entry and exit pages and funnels) 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,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).
Expand Down
11 changes: 8 additions & 3 deletions bench/visual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,5 +87,8 @@ gh run download <id> --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 <downloaded artifacts> --labels
bench/visual/labels.json`.
group). Locally, `summarize.mjs` runs on downloaded artifacts: `node bench/visual/summarize.mjs <downloaded artifacts>
--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 22): `node bench/visual/build.mjs`, `node bench/visual/corpus.mjs prepare demos`
(templates: with `VISUAL_BENCH_WORK=<dir>`, 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.
132 changes: 130 additions & 2 deletions bench/visual/faults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -511,6 +514,74 @@ export function inject(kind: string, seed: number, extra: Record<string, unknown
planted.set(id, { el: d, extra: { cover } });
return { kind: base, id, ok: true };
}
case "ad-heavy": {
// a run of full-width ad slots at the top of the content, 40% of the page once inserted
const host = document.body;
if (!host) return fail("no body");
const w = Math.min(970, document.documentElement.clientWidth);
const h = document.documentElement.scrollHeight;
const n = Math.max(1, Math.ceil((0.7 * h) / 250));
const wrap = document.createElement("div");
setImp(wrap, { display: "block", position: "relative", "z-index": "0" });
for (let i = 0; i < n; i++) {
const a = document.createElement("div");
a.className = "ad-slot";
a.id = `div-gpt-ad-bench-${seed}-${i}`;
setImp(a, { display: "block", width: `${w}px`, "max-width": "100%", height: "250px", margin: "0 auto", background: "#e5e7eb", "box-sizing": "border-box", border: "1px solid #9ca3af" });
a.setAttribute("data-bench-fault", base);
a.setAttribute("data-bench-id", id);
wrap.appendChild(a);
}
host.prepend(wrap);
used.add(wrap);
planted.set(id, { el: wrap });
return { kind: base, id, ok: true, info: { slots: n } };
}
case "text-warped:overlap":
case "text-warped:clipped":
case "text-warped:tofu":
case "text-warped:mirrored":
case "text-warped:skewed":
case "text-warped:spacing":
case "text-warped:tiny": {
const how = kind.split(":")[1];
const lines = (el: Element) => {
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%" });
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions bench/visual/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>]
// [--workers 2] [--seed 1] [--out <dir>] [--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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading