mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-24 06:23:06 +08:00
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>
907 lines
41 KiB
JavaScript
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;
|
|
});
|
|
}
|