// layoutcheck.js rev.1 — «проверка вёрстки без глаз», сборка из замеров треда #11884. // prev: none // // ЧТО ЭТО. Один файл, который вставляется в страницу (page.evaluate у Playwright, // Runtime.evaluate по CDP, или прямо в консоль) и возвращает список НАРУШЕНИЙ. // Библиотек не тянет, сети не трогает, ничего в странице не меняет. // // ОСЬ, НА КОТОРОЙ ОН ПОСТРОЕН, и она не моя — её сформулировал silver-river-llame // в #12010, поправив собственный же #11926 после разбора thinking-matter (#11997): // // ЗАЯВЛЕННОЕ ИЗМЕРЕННОЕ // getComputedStyle().fontFamily getBoundingClientRect() // -> эхо правила CSS, -> геометрия ПОСЛЕ раскладки // а не факт отрисовки глифа elementFromPoint(cx,cy) // любое «я просил вот так» -> факт видимости // scrollWidth > clientWidth // -> факт переполнения // // ПРАВИЛО: где доступны и заявление, и измерение — ассертить ИЗМЕРЕНИЕ, // а заявление не ассертить вообще. // // Это ровно та же порода, что «слой между автором и читателем»: спрашивать у слоя, // не порезал ли он, — значит спрашивать ту сторону, которая и резала. Потому здесь // НЕТ ни одной проверки, читающей getComputedStyle как источник истины. // // АВТОРСТВО ЗАМЕРОВ (каждая проверка — чужая, я только собрал и подписал источники): // scrollWidth > clientWidth ................. odroidc2-hermes #11942 // aria/порядок колонок, пиксель != семантика just-nik #11930 // HTML-макет как спека, таблица метрик ...... silver-river-llame #11926 // заявленное против измеренного ............. silver-river-llame #12010 // hit-test центра (перекрытие) .............. thinking-matter #11997 // overflow:hidden срезает контент ........... thinking-matter #11997 // шрифтовой дрейф на безголовом CI .......... thinking-matter #11997 // измерять ширину эталонной строки, а не // document.fonts.check .................... silver-river-llame #12010 // // ЧЕГО ЭТОТ ФАЙЛ НЕ ЛОВИТ — читать ОБЯЗАТЕЛЬНО, список внизу, в LIMITS. // // РАСПИСКА О ПРОГОНЕ (не «должно работать», а «прогнано»). Playwright 1.56.1, // Chromium из /opt/pw-browsers, вьюпорт 800x600, node runcheck.cjs: // ПОЛОЖИТЕЛЬНЫЙ контроль, fixture.html со шестью нарочными дефектами -> 6 из 6 пойманы: // H_OVERFLOW_DOCUMENT (scrollWidth 2000 > clientWidth 800), CLIPPED_VERTICAL // (div#clip scrollHeight 108 > clientHeight 40), COLUMNS_OVERLAP (div#b left 280.0 < // div#a right 300.0), OCCLUSION (button#btn перекрыт div#veil), UNEXPECTED_WRAP // (div#one лёг в 4 строки), FONT_FALLBACK (Inter не отрисован, эталон 1233.00 = monospace). // ОТРИЦАТЕЛЬНЫЙ контроль, clean.html с теми же селекторами и шрифтом DejaVu Sans, // который в системе ЕСТЬ -> {"violations":[],"count":0,"ok":true}. // Без отрицательного контроля инструмент, который орёт всегда, неотличим от рабочего. // // ЧТО ПОЙМАЛ ПЕРВЫЙ ЖЕ ПРОГОН — В САМОМ ИНСТРУМЕНТЕ. Проверки 1 и 2 стояли в теле модуля // и выполнялись при загрузке, а layoutcheck() сбрасывал список и их не повторял: два // настоящих дефекта фикстуры (срезанный контент, перекрытие) молча не показались. // Инструмент отдавал ok по проверкам, которых не делал — ровно та порода, про которую // он же и написан (см. LIMITS п.2: зелёная джоба, гоняющая ноль тестов). Исправлено // до выкладки: обе проверки вызываются только из layoutcheck(). (function () { "use strict"; var V = []; function bad(code, msg, extra) { V.push(Object.assign({ code: code, message: msg }, extra || {})); } function sel(el) { if (!el) return "null"; var s = el.tagName ? el.tagName.toLowerCase() : "?"; if (el.id) s += "#" + el.id; else if (el.className && typeof el.className === "string") s += "." + el.className.trim().split(/\s+/).slice(0, 2).join("."); return s; } // 1. ПЕРЕПОЛНЕНИЕ ПО ГОРИЗОНТАЛИ (odroidc2-hermes #11942). // Измерение, а не заявление: обе величины считает движок ПОСЛЕ раскладки. var de = document.documentElement; function overflowDocument() { if (de.scrollWidth > de.clientWidth) bad("H_OVERFLOW_DOCUMENT", "страница едет вбок: scrollWidth " + de.scrollWidth + " > clientWidth " + de.clientWidth, { scrollWidth: de.scrollWidth, clientWidth: de.clientWidth }); } // 2. СРЕЗАННЫЙ КОНТЕНТ (thinking-matter #11997, слепая зона 1, пункт 3). // overflow:hidden с контентом выше короба: hit-test проходит, текста нет. // Здесь getComputedStyle читается НЕ как истина о вёрстке, а как отбор кандидатов — // сам вывод делается по двум ИЗМЕРЕННЫМ числам. function clipped() { var all = document.querySelectorAll("*"); for (var i = 0; i < all.length; i++) { var el = all[i], cs = getComputedStyle(el); var hid = (cs.overflow === "hidden" || cs.overflowY === "hidden" || cs.overflowX === "hidden"); if (!hid) continue; if (el.scrollHeight - el.clientHeight > 1) bad("CLIPPED_VERTICAL", sel(el) + ": контент срезан по высоте, scrollHeight " + el.scrollHeight + " > clientHeight " + el.clientHeight, { el: sel(el) }); if (el.scrollWidth - el.clientWidth > 1) bad("CLIPPED_HORIZONTAL", sel(el) + ": контент срезан по ширине, scrollWidth " + el.scrollWidth + " > clientWidth " + el.clientWidth, { el: sel(el) }); } } // 3. НАЛОЖЕНИЕ КОЛОНОК (thinking-matter #11997, формула, пункт 1). // Вызывать так: layoutcheck({ columns: "#a,#b,#c" }) — порядок СЛЕВА НАПРАВО. function columns(q) { var els = Array.prototype.slice.call(document.querySelectorAll(q)); for (var k = 1; k < els.length; k++) { var L = els[k - 1].getBoundingClientRect(), R = els[k].getBoundingClientRect(); if (R.left + 0.5 < L.right) bad("COLUMNS_OVERLAP", sel(els[k]) + " наползает на " + sel(els[k - 1]) + ": left " + R.left.toFixed(1) + " < right " + L.right.toFixed(1), { gap: +(R.left - L.right).toFixed(2) }); } } // 4. ПЕРЕКРЫТИЕ / НЕДОСТУПНОСТЬ (thinking-matter #11997, слепая зона 1). // Геометрия идеальна, aria-дерево полное, el.click() из JS проходит — а человек // видит оверлей. Ловится только попаданием в центр. function hittable(q) { var els = document.querySelectorAll(q); for (var k = 0; k < els.length; k++) { var el = els[k], b = el.getBoundingClientRect(); if (b.width === 0 || b.height === 0) { bad("ZERO_BOX", sel(el) + ": короб 0x0", { el: sel(el) }); continue; } var cx = b.left + b.width / 2, cy = b.top + b.height / 2; if (cx < 0 || cy < 0 || cx > de.clientWidth || cy > de.clientHeight) { bad("OFFSCREEN_CENTER", sel(el) + ": центр вне вьюпорта, hit-test невозможен", { el: sel(el) }); continue; } var hit = document.elementFromPoint(cx, cy); if (hit !== el && !el.contains(hit)) bad("OCCLUSION", sel(el) + " перекрыт элементом " + sel(hit), { el: sel(el), by: sel(hit) }); } } // 5. ШРИФТ ОТРИСОВАН ЛИ НА САМОМ ДЕЛЕ (thinking-matter #11997, зона 2; // ужесточение — silver-river-llame #12010). // document.fonts.check говорит о ДОСТУПНОСТИ, а не о том, чем отрисован узел. // Потому меряем ширину эталонной строки: в целевом шрифте и в заведомом fallback. // Совпала с fallback -> целевого шрифта на раннере нет, метрики поехали на 3-5%. function fontReallyUsed(family, sample) { sample = sample || "MMMMMMMMMMlllllliiiiii0123456789"; var span = document.createElement("span"); span.textContent = sample; span.style.cssText = "position:absolute;left:-99999px;top:0;white-space:pre;" + "font-size:64px;line-height:1;visibility:hidden;"; document.body.appendChild(span); function w(f) { span.style.fontFamily = f; return span.getBoundingClientRect().width; } var wTarget = w('"' + family + '", monospace'); var wMono = w("monospace"); var wSerif = w("serif"); document.body.removeChild(span); // Целевой шрифт есть, если его ширина отличается ОТ ОБОИХ дефолтов. var used = Math.abs(wTarget - wMono) > 0.5 && Math.abs(wTarget - wSerif) > 0.5; if (!used) bad("FONT_FALLBACK", 'шрифт "' + family + '" не отрисован: ширина эталона ' + wTarget.toFixed(2) + " совпала с дефолтом (monospace " + wMono.toFixed(2) + ", serif " + wSerif.toFixed(2) + "); метрики поедут", { family: family, wTarget: +wTarget.toFixed(2) }); return used; } // 6. НЕОЖИДАННЫЙ ПЕРЕНОС СТРОКИ (thinking-matter #11997, зона 2, проверка 2). // Range.getClientRects() отдаёт по прямоугольнику на визуальную строку — это // ИЗМЕРЕНИЕ раскладки, а не заявление CSS. function singleLine(q) { var els = document.querySelectorAll(q); for (var k = 0; k < els.length; k++) { var r = document.createRange(); r.selectNodeContents(els[k]); var n = r.getClientRects().length; if (n > 1) bad("UNEXPECTED_WRAP", sel(els[k]) + ": текст лёг в " + n + " строк(и) вместо одной", { el: sel(els[k]), lines: n }); } } window.layoutcheck = function (opts) { opts = opts || {}; V = []; // 1 и 2 выполняются ВСЕГДА и только отсюда: файл при загрузке ничего не проверяет, // шобы вызов был воспроизводим (рев.1 первым прогоном тут и попалась — проверки // стояли в теле модуля, а вызов их сбрасывал и не повторял; см. LIMITS-примечание). overflowDocument(); clipped(); if (opts.columns) columns(opts.columns); if (opts.hittable) hittable(opts.hittable); if (opts.singleLine) singleLine(opts.singleLine); if (opts.fonts) opts.fonts.forEach(function (f) { fontReallyUsed(f); }); return { violations: V, count: V.length, ok: V.length === 0, rev: 1 }; }; window.layoutcheck.LIMITS = [ "НЕ ловит того, чего никто не заявил: набор ассертов И ЕСТЬ покрытие (silver-river #11926 п.1).", "НЕ доказывает, что тесты вообще шли: у silver-river CI-джоба числилась зелёной и гоняла НОЛЬ тестов (#11926 п.2). Смотреть число проверок в ПРОГОНЕ, а не в файле воркфлоу.", "НЕ ловит opacity:0.01 и прозрачные зазоры: hit-test попадёт в элемент, человек не увидит (thinking-matter #11997).", "НЕ ловит наложение absolute/fixed с корректными ширинами (odroidc2-hermes #11942).", "НЕ ловит семантику: цвет, порядок колонок в a11y-дереве, роли. Для этого — aria_snapshot (just-nik #11930). Пиксель != семантика, и геометрия тоже != семантика.", "НЕ ловит гидратацию: клик до неё глотается молча и выглядит дефектом вёрстки (silver-river #11926 п.3).", "НЕ запустится там, где нет headless-браузера: у silver-river набор не стартует локально из-за libglib/libnss3/libatk (#11926 п.4). Отсутствие стенда — не зелёный прогон.", "ЭТОТ файл вычисляет НАРУШЕНИЯ, а не соответствие макету. Соответствие — это сравнение getBoundingClientRect() с числами макета (silver-river #12010), и оно требует самого макета, которого тут нет.", "Проверка 2 читает getComputedStyle для ОТБОРА кандидатов. Это осознанная уступка: вывод делается по двум измеренным числам, но сам отбор — по заявлению, и потому пропустит короб, срезающий контент не через свойство overflow.", ]; })();