Files
codebase-memory-mcp/graph-ui/tools/lib/readability.mjs
Bernhard Jackiewicz 57090cbd2e fix(ui): let tooltips ignore fields of closed menus
Hint protected every input and select of the page, also those inside a
closed <details>. Their content is not drawn, but the browser still gives
it a box, so the closed Path to and Edge types menus of the Galaxy
toolbar pushed the Expand +1 tooltip from its place below the button
into the page header.

Only drawn elements are protected now: checkVisibility where the browser
has it, and the content of a closed <details> (other than its summary)
everywhere. The readability probe applies the same rule.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
2026-10-04 13:43:06 +02:00

907 lines
41 KiB
JavaScript

/**
* Das Lesbarkeits-Gate: liegt an dieser Stelle der Strecke etwas uebereinander,
* ragt etwas aus seinem Kasten, und was passiert, wenn man bis ans Ende
* scrollt?
*
* Bernhards Schlussanforderung vom 2026-08-29, und sie ist keine Kosmetikfrage.
* Vier Wellen Rueckmeldung in W5c handelten davon, dass Beschriftungen sich
* ueberlagerten, dass ein Kasten hinter einer Zeile verschwand und dass eine
* Leiste in ihre Nachbarn wuchs. Jedes Mal war der Befund an einem gruenen
* Testlauf nicht zu sehen und an einem Screenshot sofort. Dieses Modul macht die
* Frage messbar, damit sie an JEDEM Halt gestellt wird und nicht nur dort, wo
* gerade jemand hinsieht.
*
* ## Was gemessen wird
*
* **Kandidaten** sind sichtbare Elemente mit eigenem Text: mindestens ein
* direktes Textkind mit Zeichen darin, ein Rechteck mit Flaeche, nicht
* `display:none`, nicht `visibility:hidden`, nicht durchsichtig. Ein Element,
* das nur andere Elemente enthaelt, ist kein Kandidat: sein Rechteck ist die
* Summe seiner Kinder und wuerde jedes von ihnen ueberlagern.
*
* **Sichtbar heisst wirklich sichtbar.** Gerechnet wird nicht mit dem Rechteck
* des Elements, sondern mit dem, was davon nach allen Kaesten darueber uebrig
* bleibt: das Rechteck wird an jedem Vorfahren geschnitten, dessen `overflow`
* nicht `visible` ist, und zuletzt am Fenster. Bleibt nichts uebrig, ist das
* Element nicht auf dem Bildschirm und zaehlt nicht mit. Ohne diesen Schnitt
* waere jeder gescrollte Bereich eine Fundgrube falscher Befunde: eine Zeile,
* die im Twin nach oben aus dem Sichtfenster gewandert ist, hat weiterhin ein
* Rechteck, und das liegt dann rechnerisch ueber der Ueberschrift des Panels
* darunter, obwohl der Leser sie gar nicht sieht.
*
* **(a) Ueberlagerung.** Zwei Kandidaten derselben EBENE, von denen keiner den
* anderen enthaelt, duerfen sich mit ihren SICHTBAREN Rechtecken nicht
* schneiden. Die Ebene eines Elements ist sein naechster Vorfahre (es selbst
* eingeschlossen), der absolut, fest oder klebend positioniert ist; hat er
* keinen, ist die Ebene das Dokument. Das ist die Regel und nicht eine Liste
* von Ausnahmen, und sie trifft genau den Fehler, um den es geht: ein Overlay
* DARF ueber dem Text liegen, den es verdeckt, das ist sein Zweck. Zwei
* Beschriftungen IM Overlay duerfen es nicht. Welche Ebenen ein Halt hatte,
* steht im Ergebnis, damit die Ausnahme nachlesbar ist statt behauptet.
*
* **(b) Beschneidung.** Ein sichtbarer Kandidat darf nicht aus einem Kasten
* ragen, der ihn abschneidet und den man nicht aufscrollen kann. Gesucht wird
* der naechste Vorfahre mit `overflow: hidden` oder `clip` auf der jeweiligen
* Achse; ein `auto` oder `scroll` zaehlt nicht, denn was dort heraushaengt, ist
* erreichbar, und Erreichbarkeit ist genau der Unterschied zwischen "steht
* weiter unten" und "ist weg".
*
* Zwei Schwellen gehoeren dazu, und sie sind verschieden, weil die beiden
* Achsen verschieden sind. SENKRECHT ist der Kasten einer Textzeile fast immer
* ein paar Pixel hoeher als die Schrift darin (halbe Zeilendurchschuss ueber
* und unter den Buchstaben); ein Ueberstand von vier Pixeln schneidet dort
* nichts ab, was ein Leser sehen wuerde. Gemeldet wird deshalb erst, was mehr
* als ein gutes Drittel der Elementhoehe verdeckt. WAAGERECHT gibt es diesen
* Puffer nicht: was rechts abgeschnitten wird, sind Buchstaben. Dort zaehlt
* schon ein Ueberstand von zwei Pixeln, es sei denn, der Kasten sagt mit
* `text-overflow: ellipsis` selbst, dass er kuerzt: drei Punkte sind eine
* Ansage an den Leser und kein verschwundener Text.
*
* **(c) Eine Zeile, die an einer Bildlaufkante halb dasteht.** Ein Kasten, der
* scrollt, schneidet nichts weg: was heraushaengt, ist erreichbar, und deshalb
* meldet (b) ihn nicht. Das stimmt fuer den Explorer, in dem viele Dateien
* scrollen duerfen, und es war fuer die Legende der Galaxie zu nachsichtig
* (Bernhards Befund an den Beweisbildern von W9, 2026-08-29): dort stand ueber
* der ersten Zeile ein Rest wie "not more importance:" und unten endete ein
* Satz mitten im Wort. Die Zahlen waren gruen, die Bilder zeigten einen
* Darstellungsfehler.
*
* Gefragt wird darum am RUHEZUSTAND und an der ZEILE, und beide Einschraenkungen
* sind der Grund, dass die Regel eng bleibt:
*
* - **Ruhezustand.** Nur ein Kasten, der noch am Anfang steht
* (`scrollTop`/`scrollLeft` bei null), wird gefragt. Die Regel stellt die
* Frage, die ein Leser beim OEFFNEN stellt: "ist das alles?". Wer selbst
* gescrollt hat, hat die Antwort schon; und ein Kasten, den die Messung
* unter (c) selbst ans Ende gefahren hat, zeigt einen Bildschirm, den dieses
* Produkt nie ausliefert.
* - **Zeile.** Gemeldet wird nicht, dass Inhalt ausserhalb steht (das ist der
* Sinn eines Bildlaufs), sondern dass die Kante MITTEN DURCH eine Textzeile
* geht: ein Teil der Zeile ist zu sehen, der andere nicht. Gemessen an den
* Zeilenrechtecken des Textes selbst (`Range.getClientRects`) und nicht am
* Rechteck des Absatzes, denn ein fuenfzeiliger Absatz, dem die halbe letzte
* Zeile fehlt, ist an seinem Gesamtrechteck fast vollstaendig.
*
* Steht die Kante mitten in einer Zeile, dann muss der Kasten das SAGEN. Als
* Hinweis zaehlt, was ein Leser im Bild sieht: eine Marke mit
* `data-scroll-hint` fuer diese Kante, ein Verlauf ueber der Kante (eine
* Maske), ein Streifen mit einem Farbverlauf, der an dieser Kante liegt (so
* macht es die Tab-Leiste), ein mitrollender Hintergrund (das Schatten-Idiom
* mit `background-attachment: local`) oder eine Bildlaufleiste, die wirklich
* Platz einnimmt. Fehlt alles davon, ist es eine Verletzung: der Leser sieht einen
* abgeschnittenen Satz und nichts, was ihm sagt, dass es weitergeht. Die
* Verletzung steht in derselben Liste wie (b) und traegt `kind`, damit jeder
* Lauf sie ohne eigene Aenderung mitzaehlt und ein Leser des Artefakts die
* beiden Faelle trotzdem auseinanderhaelt.
*
* **(d) Scrollen.** Jeder sichtbare Bereich, der wirklich mehr Inhalt hat als
* Platz, wird ans Ende gefahren und dort erneut nach (a) und (b) gefragt. Ein
* Panel, das oben ordentlich aussieht und unten kollidiert, ist ein Panel, das
* kollidiert.
*
* ## Die eine Ausnahme, und warum sie eine ist
*
* Die Innereien des Editors werden nicht gemessen. Monaco setzt seine Zeilen,
* seine Zeilennummern und seine Dekorationen selbst, absolut positioniert und
* pixelgenau, und es tut das in einer eigenen Welt aus ueberlagerten Ebenen.
* Sie zu messen hiesse, eine fremde Bibliothek gegen die Layoutregeln dieses
* Produkts zu pruefen. Der Editor als FLAECHE wird gemessen, seine Nachbarn
* auch; was er in sich zeichnet, verantwortet er. Die Ausnahme steht als
* `exclusions` im Ergebnis.
*/
/**
* **(e) Die Tooltips, und warum sie eine eigene Frage brauchen.**
*
* Bis W8b erklaerte sich diese Oberflaeche mit dem `title`-Attribut, 78 Mal.
* Der Browser zeichnet daraus einen Kasten AUSSERHALB des Dokuments, unter dem
* Mauszeiger und ohne Rechteck. Er hat damit keine der Eigenschaften, an denen
* (a) bis (d) messen: kein `getBoundingClientRect`, keine Ebene, kein
* Vorfahre. Es gab also 78 Flaechen, die sich ueber beliebigen Inhalt legen
* konnten, und null Messung darauf. Der Nutzer hat am 2026-08-29 fotografiert,
* was daraus folgt: der Tooltip der Kopfzeile lag ueber dem Detail-Regler und
* ueber den Chips Logic, Calls und Data.
*
* Seit W8b zeichnet diese Oberflaeche ihre Tooltips selbst (src/ui/tooltip/).
* Sie liegen im Dokument, also sieht (a) sie grundsaetzlich; nur greift die
* Ebenen-Regel bei ihnen absichtlich nicht, denn ein Tooltip ist fest
* positioniert und damit seine EIGENE Ebene. Das ist richtig so: ein Tooltip
* DARF ueber Fliesstext liegen, das ist seine Bauart. Was er nicht darf, ist
* das hier, und es ist eine engere Frage als die von (a):
*
* Ein offener Tooltip darf nichts verdecken, was der Leser gerade BRAUCHT.
*
* Gebraucht heisst geschuetzt, und geschuetzt ist genau dreierlei: der
* Ausloeser selbst (ein Kasten, der den Knopf verdeckt, den man beruehrt, nimmt
* dem Zeiger sein Ziel), Regler und Eingabefelder, und die Flaechen, die die
* Oberflaeche mit `data-hint-keep` als Beschriftung ihrer Sektion ausweist.
* Dieselbe Liste, nach der die Oberflaeche selbst platziert (siehe
* src/ui/tooltip/tooltip-model.ts): zwei Formulierungen derselben Regel waeren
* zwei Regeln, und der Beweislauf soll die Zusicherung pruefen und nicht eine
* zweite daneben aufstellen.
*
* Der Lauf oeffnet dafuer JEDEN Tooltip einzeln und misst; die Zahl der so
* gemessenen steht als `domTooltipsMeasured` im Artefakt. Ein Lauf, der einmal
* hinsieht und "keine Ueberlagerung" meldet, haette ueber die 77 anderen
* nichts gesagt.
*/
/**
* Der Selektor, dessen Inhalt nicht gemessen wird, mit Begruendung.
*
* Als Datenstruktur und nicht als Kommentar, weil sie in das Beweisartefakt
* gehoert: eine Ausnahme, die nur im Quelltext steht, kann ein Leser des
* Ergebnisses nicht sehen.
*/
export const READABILITY_EXCLUSIONS = [
{
selector: '.monaco-editor',
reason:
'Der Editor setzt Zeilen, Zeilennummern und Dekorationen selbst, absolut positioniert '
+ 'und in eigenen Ebenen. Gemessen wird seine Flaeche gegen die Nachbarn, nicht sein '
+ 'Innenleben: das waere eine Pruefung einer fremden Bibliothek gegen die Layoutregeln '
+ 'dieses Produkts.',
},
];
/**
* Bewusste Overlays, die per Bauart ueber anderem Text liegen.
*
* Sie brauchen keine Sonderregel im Code (die Ebenen-Regel oben deckt sie ab),
* aber sie gehoeren benannt: ein Leser des Artefakts soll wissen, WELCHE
* Ueberlagerungen dieses Produkt absichtlich zeigt.
*/
export const DELIBERATE_OVERLAYS = [
{ selector: '[data-testid="atlas-search-results"]', reason: 'Das Suchfenster liegt ueber der Kommandozeile.' },
{ selector: '[data-testid="atlas-entry"]', reason: 'Der Einstiegsdialog liegt ueber der Editorflaeche.' },
{ selector: '[data-testid="atlas-why"]', reason: 'Die Frage nach dem Warum fuellt die leere Editorflaeche.' },
{ selector: '[data-testid="codeatlas-evidence-popover"]', reason: 'Ein Beleg klappt ueber die Zeile auf, zu der er gehoert.' },
{ selector: '[data-testid="atlas-help"]', reason: 'Die Hilfeseite liegt ueber der Editorflaeche.' },
];
/**
* Vier Flaechen, die bis W8 in dieser Liste standen und es nicht mehr tun.
*
* Der Flow-Erklaerer, das Antwort-Panel, der BUG-Assistent und die
* Aenderungsansicht lagen ueber der Editorflaeche; seit W8 sind sie Reiter des
* Erklaeren-Bereichs und liegen NEBEN dem Reader, nicht davor. Sie hier
* stehenzulassen waere eine dokumentierte Ausnahme fuer etwas, das keine mehr
* braucht, und ein Leser des Artefakts wuerde eine Ueberlagerung vermuten, die
* es nicht mehr gibt. Der Satz bleibt trotzdem stehen, weil die Liste eine
* Aussage ueber die Oberflaeche ist und diese Aussage sich geaendert hat.
*/
export const OVERLAYS_TURNED_INTO_ZONES = [
'[data-testid="atlas-flow-overlay"]',
'[data-testid="atlas-chat"]',
'[data-testid="atlas-bugwizard"]',
'[data-testid="atlas-impact"]',
];
/**
* Die Messung, als Zeichenkette fuer `page.evaluate`.
*
* Sie steht als eine Funktion da, weil sie im Browser laeuft und dort nichts
* aus diesem Modul sieht.
*/
const PROBE = (exclusions) => {
const excluded = (node) => exclusions.some((selector) => node.closest(selector) !== null);
const identify = (node) => {
const testId = node.getAttribute('data-testid');
if (testId !== null && testId.length > 0) {
return `[${testId}]`;
}
const classes = (node.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
return classes.length > 0 ? `.${classes[0]}` : node.tagName.toLowerCase();
};
const path = (node) => {
const parts = [];
let current = node;
for (let i = 0; i < 4 && current !== null && current !== document.body; i += 1) {
parts.unshift(identify(current));
current = current.parentElement;
}
return parts.join(' > ');
};
const ownText = (node) => {
let text = '';
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.nodeValue ?? '';
}
}
return text.replace(/\s+/g, ' ').trim();
};
const viewport = { width: window.innerWidth, height: window.innerHeight };
const candidates = [];
const layerKeys = new Set();
/**
* Was von einem Rechteck nach allen Kaesten darueber uebrig bleibt.
*
* Jeder Vorfahre mit `overflow != visible` schneidet auf seiner Achse, und
* zuletzt schneidet das Fenster. Das Ergebnis ist die Flaeche, die ein
* Leser wirklich sieht.
*/
const visibleRectOf = (node) => {
const rect = node.getBoundingClientRect();
let left = rect.left;
let top = rect.top;
let right = rect.right;
let bottom = rect.bottom;
for (
let current = node.parentElement;
current !== null && current !== document.documentElement;
current = current.parentElement
) {
const style = window.getComputedStyle(current);
if (style.overflowX === 'visible' && style.overflowY === 'visible') {
continue;
}
const box = current.getBoundingClientRect();
if (style.overflowX !== 'visible') {
left = Math.max(left, box.left);
right = Math.min(right, box.right);
}
if (style.overflowY !== 'visible') {
top = Math.max(top, box.top);
bottom = Math.min(bottom, box.bottom);
}
}
left = Math.max(left, 0);
top = Math.max(top, 0);
right = Math.min(right, viewport.width);
bottom = Math.min(bottom, viewport.height);
return { x: left, y: top, width: right - left, height: bottom - top };
};
for (const node of document.body.querySelectorAll('*')) {
if (excluded(node)) {
continue;
}
const text = ownText(node);
if (text.length === 0) {
continue;
}
const style = window.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) < 0.05) {
continue;
}
const rect = node.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
// Weggescrollt oder ausserhalb des Fensters heisst: der Leser sieht es
// gerade nicht. Es dort zu messen waere eine Aussage ueber einen
// Bildschirm, den es nicht gibt.
const visible = visibleRectOf(node);
if (visible.width <= 0.5 || visible.height <= 0.5) {
continue;
}
// Die Ebene: der naechste positionierte Vorfahre, sich selbst eingeschlossen.
let layerNode = null;
for (let current = node; current !== null && current !== document.body; current = current.parentElement) {
const position = window.getComputedStyle(current).position;
if (position === 'absolute' || position === 'fixed' || position === 'sticky') {
layerNode = current;
break;
}
}
const layer = layerNode === null ? 'document' : path(layerNode);
layerKeys.add(layer);
candidates.push({
node,
layer,
text: text.slice(0, 80),
path: path(node),
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
visible,
});
}
// ------------------------------------------------------ (a) Ueberlagerung
const overlaps = [];
for (let i = 0; i < candidates.length; i += 1) {
for (let j = i + 1; j < candidates.length; j += 1) {
const a = candidates[i];
const b = candidates[j];
if (a.layer !== b.layer) {
continue;
}
if (a.node.contains(b.node) || b.node.contains(a.node)) {
continue;
}
const overlapX = Math.min(a.visible.x + a.visible.width, b.visible.x + b.visible.width)
- Math.max(a.visible.x, b.visible.x);
const overlapY = Math.min(a.visible.y + a.visible.height, b.visible.y + b.visible.height)
- Math.max(a.visible.y, b.visible.y);
if (overlapX > 1 && overlapY > 1) {
overlaps.push({
layer: a.layer,
a: { path: a.path, text: a.text, rect: a.visible },
b: { path: b.path, text: b.text, rect: b.visible },
overlapX: Number(overlapX.toFixed(2)),
overlapY: Number(overlapY.toFixed(2)),
});
}
}
}
// --------------------------------- (c) Der Hinweis auf mehr Inhalt
/**
* Ob ein scrollender Kasten an dieser Kante sagt, dass es weitergeht.
*
* Fuenf Formen, und jede davon ist im Bild zu sehen. Die Reihenfolge ist
* die ihrer Genauigkeit: eine Marke nennt ihre Kante selbst, ein Streifen
* mit Verlauf liegt an einer bestimmten Kante, eine Maske und ein
* mitrollender Hintergrund gelten fuer den ganzen Kasten, und eine
* Bildlaufleiste zaehlt nur, wenn sie wirklich Platz einnimmt (auf dieser
* Plattform tut sie das nicht, sie liegt ueberlagernd und ist im
* Ruhezustand unsichtbar).
*/
const showsMore = (box, edge) => {
const marks = [
...box.querySelectorAll('[data-scroll-hint]'),
...(box.parentElement === null
? []
: [...box.parentElement.children].filter((child) =>
child !== box && child.hasAttribute('data-scroll-hint'))),
];
for (const mark of marks) {
const style = window.getComputedStyle(mark);
const rect = mark.getBoundingClientRect();
const seen = rect.width > 0 && rect.height > 0
&& style.display !== 'none' && style.visibility !== 'hidden'
&& Number(style.opacity) >= 0.05;
const says = (mark.getAttribute('data-scroll-hint') ?? '').split(/\s+/);
if (seen && (says.includes(edge) || says.includes('any'))) {
return { how: 'mark', detail: mark.getAttribute('data-scroll-hint') ?? '' };
}
}
const style = window.getComputedStyle(box);
const masks = [style.maskImage, style.webkitMaskImage]
.filter((value) => typeof value === 'string' && value.length > 0 && value !== 'none');
if (masks.length > 0) {
return { how: 'mask', detail: masks[0].slice(0, 60) };
}
/*
* Ein Verlauf, der ueber dieser Kante liegt: ein sichtbares Geschwister
* im selben Rahmen, das die Kante beruehrt und einen Farbverlauf malt.
* Die Tab-Leiste macht es so (zwei Streifen an den Raendern, die nur
* erscheinen, wenn wirklich etwas herausragt), und diese Form ist
* genauso ein Hinweis wie eine Marke: der Leser sieht, dass der Text
* dort nicht endet, sondern weitergeht.
*/
const boxRect = box.getBoundingClientRect();
const near = 26;
for (const other of box.parentElement === null ? [] : box.parentElement.children) {
if (other === box) {
continue;
}
const otherStyle = window.getComputedStyle(other);
if (!/gradient/.test(otherStyle.backgroundImage ?? '')) {
continue;
}
if (otherStyle.display === 'none' || otherStyle.visibility === 'hidden'
|| Number(otherStyle.opacity) < 0.05) {
continue;
}
const rect = other.getBoundingClientRect();
const touches = edge === 'bottom'
? Math.abs(rect.bottom - boxRect.bottom) <= near && rect.top < boxRect.bottom
: edge === 'top'
? Math.abs(rect.top - boxRect.top) <= near && rect.bottom > boxRect.top
: edge === 'right'
? Math.abs(rect.right - boxRect.right) <= near && rect.left < boxRect.right
: Math.abs(rect.left - boxRect.left) <= near && rect.right > boxRect.left;
if (touches) {
return { how: 'gradient-edge', detail: identify(other) };
}
}
if ((style.backgroundAttachment ?? '').includes('local')) {
return { how: 'scrolling-background', detail: style.backgroundAttachment };
}
const border = (axis) => (axis === 'y'
? Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth)
: Number.parseFloat(style.borderLeftWidth) + Number.parseFloat(style.borderRightWidth));
const bar = edge === 'top' || edge === 'bottom'
? box.offsetWidth - box.clientWidth - border('x')
: box.offsetHeight - box.clientHeight - border('y');
if (bar > 1) {
return { how: 'scrollbar', detail: `${Number(bar.toFixed(2))}px` };
}
return null;
};
/** Die Rechtecke der einzelnen Zeilen des eigenen Textes eines Elements. */
const lineRects = (node) => {
const rects = [];
const range = document.createRange();
for (const child of node.childNodes) {
if (child.nodeType !== Node.TEXT_NODE || (child.nodeValue ?? '').trim().length === 0) {
continue;
}
range.selectNodeContents(child);
rects.push(...range.getClientRects());
}
return rects;
};
// ------------------------------------------------------- (b) Beschneidung
const clipped = [];
const cutting = (style, axis) => {
const value = axis === 'x' ? style.overflowX : style.overflowY;
return value === 'hidden' || value === 'clip';
};
for (const candidate of candidates) {
for (const axis of ['x', 'y']) {
let clipper = null;
let scroller = null;
for (
let current = candidate.node.parentElement;
current !== null && current !== document.documentElement;
current = current.parentElement
) {
const style = window.getComputedStyle(current);
if (cutting(style, axis)) {
clipper = current;
break;
}
if ((axis === 'x' ? style.overflowX : style.overflowY) !== 'visible') {
// Ein scrollbarer Kasten schneidet nicht ab: was heraushaengt,
// ist erreichbar. Die Suche endet hier trotzdem, denn er
// begrenzt, was ein aeusserer Kasten ueberhaupt sehen kann.
// Ob er es dem Leser SAGT, fragt (c) gleich darunter.
scroller = current;
break;
}
}
if (scroller !== null) {
/*
* (c): geht die Kante mitten durch eine Zeile, und steht dort
* ein Hinweis? Nur am Anfang des Bildlaufs, also an der fernen
* Kante; siehe Kopf.
*/
const room = axis === 'y'
? scroller.scrollHeight - scroller.clientHeight
: scroller.scrollWidth - scroller.clientWidth;
const offset = axis === 'y' ? scroller.scrollTop : scroller.scrollLeft;
if (room > 1 && offset <= 1) {
const box = scroller.getBoundingClientRect();
const border = window.getComputedStyle(scroller);
const far = axis === 'y'
? box.bottom - Number.parseFloat(border.borderBottomWidth)
: box.right - Number.parseFloat(border.borderRightWidth);
for (const line of lineRects(candidate.node)) {
const start = axis === 'y' ? line.top : line.left;
const end = axis === 'y' ? line.bottom : line.right;
const shown = far - start;
const gone = end - far;
/*
* Ein Viertel der Zeile auf jeder Seite, mindestens
* zwei Pixel. Der Kasten einer Zeile ist hoeher als
* ihre Schrift (halber Zeilendurchschuss oben und
* unten), und ein Schnitt, der nur diesen Rand
* erwischt, zeigt keine halbe Zeile, sondern gar
* keine. Gefragt ist der Fall, den ein Leser als
* abgeschnittenen Satz sieht.
*/
const bite = Math.max(2, (end - start) * 0.25);
if (shown <= bite || gone <= bite) {
continue;
}
const hint = showsMore(scroller, axis === 'y' ? 'bottom' : 'right');
if (hint === null) {
clipped.push({
kind: 'cut-without-hint',
axis,
element: {
path: candidate.path,
text: candidate.text,
rect: candidate.rect,
},
container: {
path: path(scroller),
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
hidden: Number(room.toFixed(2)),
},
overflowPx: Number(gone.toFixed(2)),
shownPx: Number(shown.toFixed(2)),
});
}
// Hoechstens eine Meldung je Element und Achse: die
// Kante geht durch genau eine Zeile, und die ist
// gefunden.
break;
}
}
}
if (clipper === null) {
continue;
}
const box = clipper.getBoundingClientRect();
const near = candidate.rect;
const onScreen = near.x + near.width > box.x && near.x < box.x + box.width
&& near.y + near.height > box.y && near.y < box.y + box.height;
if (!onScreen) {
continue;
}
const over = axis === 'x'
? Math.max(box.x - near.x, (near.x + near.width) - (box.x + box.width))
: Math.max(box.y - near.y, (near.y + near.height) - (box.y + box.height));
/*
* Zwei verschiedene Schwellen, weil die Achsen verschieden sind.
* Senkrecht ist der Kasten einer Zeile immer etwas hoeher als ihre
* Schrift; waagerecht sind abgeschnittene Pixel Buchstaben, ausser
* der Kasten kuerzt selbst mit drei Punkten.
*/
const ellipsis = window.getComputedStyle(clipper).textOverflow === 'ellipsis'
|| window.getComputedStyle(candidate.node).textOverflow === 'ellipsis';
const threshold = axis === 'y'
? Math.max(4, near.height * 0.35)
: (ellipsis ? Number.POSITIVE_INFINITY : 2);
if (over > threshold) {
clipped.push({
kind: 'clipped',
axis,
element: { path: candidate.path, text: candidate.text, rect: near },
container: { path: path(clipper), rect: { x: box.x, y: box.y, width: box.width, height: box.height } },
overflowPx: Number(over.toFixed(2)),
});
}
}
}
return {
candidates: candidates.length,
layers: [...layerKeys].sort(),
overlaps,
clipped,
viewport,
};
};
/** Die Messung an dieser Stelle der Strecke. */
export async function measureReadability(page, exclusions = READABILITY_EXCLUSIONS) {
return page.evaluate(PROBE, exclusions.map((entry) => entry.selector));
}
/**
* Jeden wirklich scrollbaren Bereich ans Ende fahren.
*
* Zurueck kommt, was gescrollt wurde, mit einem Namen je Bereich. Die Namen
* sind die Testmarken oder die erste CSS-Klasse; das reicht, um zwei Bereiche
* zu unterscheiden, und ist stabil ueber die Halte hinweg.
*/
export async function scrollRegionsToEnd(page, exclusions = READABILITY_EXCLUSIONS) {
return page.evaluate((selectors) => {
const excluded = (node) => selectors.some((selector) => node.closest(selector) !== null);
const identify = (node) => {
const testId = node.getAttribute('data-testid');
if (testId !== null && testId.length > 0) {
return testId;
}
const classes = (node.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
return classes.length > 0 ? classes[0] : node.tagName.toLowerCase();
};
const scrolled = [];
for (const node of document.body.querySelectorAll('*')) {
if (excluded(node)) {
continue;
}
const rect = node.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const style = window.getComputedStyle(node);
const scrollableY = (style.overflowY === 'auto' || style.overflowY === 'scroll')
&& node.scrollHeight > node.clientHeight + 1;
const scrollableX = (style.overflowX === 'auto' || style.overflowX === 'scroll')
&& node.scrollWidth > node.clientWidth + 1;
if (!scrollableY && !scrollableX) {
continue;
}
const before = { top: node.scrollTop, left: node.scrollLeft };
if (scrollableY) {
node.scrollTop = node.scrollHeight;
}
if (scrollableX) {
node.scrollLeft = node.scrollWidth;
}
scrolled.push({
name: identify(node),
axis: scrollableY && scrollableX ? 'both' : scrollableY ? 'y' : 'x',
before,
after: { top: node.scrollTop, left: node.scrollLeft },
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
scrollWidth: node.scrollWidth,
clientWidth: node.clientWidth,
});
}
return scrolled;
}, exclusions.map((entry) => entry.selector));
}
/**
* Alle Bereiche wieder an den Anfang, damit der naechste Schritt oben beginnt.
*
* Die ausgenommenen Flaechen bleiben unberuehrt: dem Editor seinen Bildlauf von
* aussen zu setzen hiesse, an einer Bibliothek zu drehen, die ihren eigenen
* Zustand darueber fuehrt.
*/
export async function resetScroll(page, exclusions = READABILITY_EXCLUSIONS) {
await page.evaluate((selectors) => {
const excluded = (node) => selectors.some((selector) => node.closest(selector) !== null);
for (const node of document.body.querySelectorAll('*')) {
if (excluded(node)) {
continue;
}
if (node.scrollTop !== 0) {
node.scrollTop = 0;
}
if (node.scrollLeft !== 0) {
node.scrollLeft = 0;
}
}
}, exclusions.map((entry) => entry.selector));
}
// ---------------------------------------------------------------- (e) Tooltips
/**
* Was ein offener Tooltip nicht verdecken darf, als Daten und mit Grund.
*
* WORTGLEICH mit `HINT_PROTECTED` in src/ui/tooltip/tooltip-model.ts, und das
* ist Absicht: die Oberflaeche platziert nach dieser Liste, dieser Lauf misst
* nach derselben. Sie hier zu erweitern, ohne sie dort zu erweitern, hiesse
* eine Zusicherung zu pruefen, die das Produkt gar nicht gibt.
*/
export const TOOLTIP_PROTECTED = [
{
selector: 'input, textarea, select',
reason:
'Regler und Eingabefelder. Genau der Fall aus dem Screenshot vom 2026-08-29: '
+ 'ein Tooltip lag ueber dem Detail-Regler des Twin.',
},
{
selector: '[data-hint-keep]',
reason:
'Die Beschriftungen und Schalter der Sektion, in der der Ausloeser steht. Wer einen '
+ 'Knopf beruehrt, um zu erfahren was er tut, schaut danach auf seine Nachbarn.',
},
];
/**
* Alle Ausloeser, die diese Seite gerade traegt.
*
* Zurueck kommt eine Liste von Griffen, an denen der Lauf sie einzeln oeffnen
* kann: die laufende Nummer (stabil ueber einen Halt), der Name, den die
* Oberflaeche dem Tooltip gegeben hat, und der Satz. Nur SICHTBARE, denn ein
* Ausloeser ohne Rechteck laesst sich weder beruehren noch fokussieren, und ein
* Bericht ueber ihn waere ein Bericht ueber einen Bildschirm, den es nicht gibt.
*/
export async function tooltipTriggers(page) {
return page.evaluate(() => {
const out = [];
let index = 0;
for (const node of document.querySelectorAll('[data-hint]')) {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
const seen = rect.width > 0 && rect.height > 0
&& style.display !== 'none' && style.visibility !== 'hidden'
&& Number(style.opacity) >= 0.05
&& rect.bottom > 0 && rect.top < window.innerHeight
&& rect.right > 0 && rect.left < window.innerWidth;
node.setAttribute('data-hint-index', String(index));
if (seen) {
out.push({
index,
name: node.getAttribute('data-hint-name') ?? '',
text: node.getAttribute('data-hint') ?? '',
tag: node.tagName.toLowerCase(),
focusable: node.tabIndex >= 0,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
});
}
index += 1;
}
return out;
});
}
/**
* Einen Tooltip oeffnen und messen, was er verdeckt.
*
* `how` ist `pointer` oder `keyboard`, und der Unterschied ist die Zusicherung
* aus AC1: ein Tooltip, den nur die Maus oeffnet, ist keiner. Beide Wege gehen
* durch dieselben Rueckrufe der Oberflaeche, also misst der Lauf zweimal
* dasselbe Versprechen und nicht zwei verschiedene.
*
* Zurueck kommt, ob der Kasten wirklich dasteht, wo er steht, und die Liste der
* geschuetzten Flaechen, die er schneidet. Leer ist die bestandene Antwort.
*/
export async function measureTooltip(page, index, how = 'pointer') {
return page.evaluate(({ at, mode, selectors }) => {
const trigger = document.querySelector(`[data-hint-index="${at}"]`);
if (trigger === null) {
return { opened: false, reason: 'trigger gone' };
}
if (mode === 'keyboard') {
trigger.focus?.();
trigger.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
} else {
const rect = trigger.getBoundingClientRect();
trigger.dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
}));
}
return {
opened: true,
described: trigger.getAttribute('aria-describedby') ?? '',
};
}, { at: index, mode: how });
}
/** Die Selektoren der geschuetzten Flaechen, in einem Stueck. */
const protectedSelector = () => TOOLTIP_PROTECTED.map((entry) => entry.selector).join(', ');
/**
* Was der gerade offene Tooltip verdeckt.
*
* Getrennt vom Oeffnen, weil zwischen beidem ein Bild liegen muss: der Kasten
* misst sich selbst, bevor er sich stellt (siehe src/ui/tooltip/Hint.tsx), und
* ein Lauf, der sofort nachschaut, sieht die erste, unsichtbare Lage.
*/
export async function tooltipCover(page, index) {
return page.evaluate(({ at, protectSelector }) => {
const box = document.querySelector('[data-testid="atlas-hint"]');
if (box === null) {
return { open: false, covers: [], rect: null, name: '' };
}
const style = window.getComputedStyle(box);
const rect = box.getBoundingClientRect();
if (style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) {
return { open: false, covers: [], rect: null, name: box.getAttribute('data-hint-for') ?? '' };
}
const trigger = document.querySelector(`[data-hint-index="${at}"]`);
const overlap = (other) => {
const width = Math.min(rect.right, other.right) - Math.max(rect.left, other.left);
const height = Math.min(rect.bottom, other.bottom) - Math.max(rect.top, other.top);
return width > 1 && height > 1 ? Math.round(width * height) : 0;
};
const covers = [];
const candidates = [...document.querySelectorAll(protectSelector)];
if (trigger !== null) {
candidates.push(trigger);
}
for (const node of candidates) {
if (node === box || box.contains(node) || node.contains(box)) {
continue;
}
// Dieselbe Regel wie `rendered` in src/ui/tooltip/Hint.tsx: der Inhalt eines zugeklappten <details> ist nicht zu sehen.
const closed = node.closest('details:not([open])');
if ((closed !== null && !(closed.querySelector(':scope > summary')?.contains(node) ?? false))
|| (typeof node.checkVisibility === 'function' && !node.checkVisibility())) {
continue;
}
const other = node.getBoundingClientRect();
if (other.width <= 0 || other.height <= 0) {
continue;
}
const nodeStyle = window.getComputedStyle(node);
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') {
continue;
}
const area = overlap(other);
if (area > 0) {
covers.push({
what: node === trigger
? 'its own trigger'
: node.getAttribute('data-hint-keep')
?? node.getAttribute('data-testid')
?? node.tagName.toLowerCase(),
area,
});
}
}
return {
open: true,
name: box.getAttribute('data-hint-for') ?? '',
side: box.getAttribute('data-side') ?? '',
text: (box.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 90),
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
covers,
};
}, { at: index, protectSelector: protectedSelector() });
}
/** Alles wieder zu: der naechste Halt soll nicht mit einem offenen Kasten beginnen. */
export async function closeTooltips(page) {
await page.evaluate(() => {
for (const node of document.querySelectorAll('[data-hint][data-hint-open="true"]')) {
node.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
node.blur?.();
node.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
});
}
/**
* Wie viele native Tooltips diese Seite noch traegt, die etwas ERKLAEREN.
*
* Die Zusicherung aus AC1, als Zahl. Gezaehlt wird nicht "hat ein title",
* sondern "hat einen title, der mehr sagt als der sichtbare Text": ein
* Attribut, das nur wiederholt, was danebensteht, verdeckt zwar auch etwas, ist
* aber ein anderer Fehler und faellt in W8b ersatzlos weg. Dieselbe Bedingung
* wie `explainsMore` in src/ui/tooltip/tooltip-model.ts.
*/
export async function nativeTitles(page) {
return page.evaluate(() => {
const tidy = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase();
const out = [];
for (const node of document.querySelectorAll('[title]')) {
if (node.closest('.monaco-editor') !== null) {
continue;
}
const said = tidy(node.getAttribute('title') ?? '');
if (said.length === 0) {
continue;
}
const shown = tidy(node.textContent ?? '');
if (shown.includes(said)) {
continue;
}
out.push({
path: node.tagName.toLowerCase()
+ (node.getAttribute('data-testid') === null
? ''
: `[${node.getAttribute('data-testid')}]`),
title: said.slice(0, 90),
text: shown.slice(0, 60),
});
}
return out;
});
}