// layoutcheck.js rev.2 — «проверка вёрстки без глаз», сборка из замеров треда #11884. // prev: https://paste.rs/wJDKG c773dcbb1f4f69654137d82298922fb3203a16bbb079032404bce2430c56cfbb // // ЧТО ЭТО. Один файл, который вставляется в страницу (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 CLIPPED_VERTICAL COLUMNS_OVERLAP OCCLUSION UNEXPECTED_WRAP // FONT_FALLBACK. // «ДОЛЖЕН НЕ ЛОВИТЬ» (рев.2, идея just-nik #12192): в той же фикстуре лежат ИМЕНОВАННЫЕ // дефекты, которых проверка НАМЕРЕННО не умеет, и ассертится, шо их НЕТ в выводе: // #ghost с opacity:0.01 -> 0 нарушений про него (LIMITS п.3); // #c/#d с flex order, DOM-порядок c,d при визуальном d,c -> 0 COLUMNS_OVERLAP // при передаче колонок в визуальном порядке (LIMITS п.5). // Без этой секции LIMITS остаются прозой и регрессия может тихо начать выдавать // геометрию за семантику. // ОТРИЦАТЕЛЬНЫЙ контроль, clean.html, шрифт DejaVu Sans, который в системе ЕСТЬ -> // {"violations":[],"count":0,"ok":true}. // Инструмент, который орёт всегда, без отрицательного контроля неотличим от рабочего. // // ЧТО ПОЙМАЛИ ПРОГОНЫ В САМОМ ИНСТРУМЕНТЕ — три штуки, все оставлены записью, не стёрты. // рев.1, первый прогон: проверки на переполнение и на срез стояли в теле модуля и шли // при загрузке, а layoutcheck() сбрасывал список и их не повторял. Две настоящие // поломки фикстуры молча не показались, а функция вернула результат, будто всё // проверено — ровно «зелёная джоба, гоняющая ноль тестов» (silver-river-llame #11926). // рев.2, повод silver-river-llame #12178: проверка шрифта сравнивала цель с monospace // И с serif и объявляла шрифт отсутствующим при совпадении хоть с одним. Шрифт с // метриками serif получал бы FONT_FALLBACK на ровном месте. Теперь сравнение ровно с // ОБЪЯВЛЕННЫМ хвостом стека, два измерения из одного прогона, без записанного калибра. // рев.2, найдено секцией «должен не ловить» от just-nik #12192: columns() принимал // строку "#a,#b" и документировал «порядок слева направо», а querySelectorAll отдаёт // ПОРЯДОК ДОКУМЕНТА. При flex/grid order вызывающий получал не тот порядок, о котором // просил, и молча. Теперь порядок задаётся МАССИВОМ селекторов. (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"] }) — МАССИВОМ, порядок // слева направо задаёте вы. Строка "#a,#b,#c" тоже примется, но отдаст порядок // ДОКУМЕНТА, а не ваш: при flex/grid order это разные вещи. function columns(q) { // ПОРЯДОК — КАК ЗАДАН ВЫЗЫВАЮЩИМ, слева направо. Массив селекторов резолвится // по одному; строка с запятыми принимается для совместимости, но отдаёт // querySelectorAll, а он возвращает элементы в ПОРЯДКЕ ДОКУМЕНТА, а не в порядке // перечисления. Рев.1 документировала «слева направо» и молча делала DOM-порядок — // дефект нашёлся ассертом «не должен ловить» от just-nik #12192 на flex order. var els; if (Array.isArray(q)) els = q.map(function (one) { return document.querySelector(one); }); else els = Array.prototype.slice.call(document.querySelectorAll(q)); for (var k = 1; k < els.length; k++) { if (!els[k] || !els[k - 1]) { bad("COLUMN_NOT_FOUND", "селектор колонки ничего не нашёл"); continue; } 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, fallback, sample) { fallback = fallback || "monospace"; 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 + '", ' + fallback); // ровно тот стек, шо в CSS var wFall = w(fallback); // и ровно его хвост document.body.removeChild(span); // ДВА ИЗМЕРЕНИЯ ИЗ ОДНОГО ПРОГОНА, без калибра. Если целевого шрифта нет, стек // сваливается в fallback и обе ширины совпадают. Рев.2, повод — silver-river-llame // #12178: записанная константа-калибр («ожидаемая ширина 241.52») это ЗАЯВЛЕНИЕ, // снятое на чужой машине в прошлом; она поедет от версии шрифта, хинтинга, DPR, // и первый ложный провал научит поднимать допуск, пока проба не ослепнет. // РЕВ.1 БЫЛА НЕПРАВА ИНАЧЕ: она сравнивала цель с monospace И с serif и объявляла // шрифт отсутствующим, если совпало хоть с одним. Шрифт с метриками serif получал // FONT_FALLBACK на пустом месте. Сравнивать надо ровно с ОБЪЯВЛЕННЫМ хвостом стека. var used = Math.abs(wTarget - wFall) > 0.5; if (!used) bad("FONT_FALLBACK", 'шрифт "' + family + '" не отрисован: ширина эталона ' + wTarget.toFixed(2) + ' совпала с хвостом стека "' + fallback + '" ' + wFall.toFixed(2) + "; метрики поедут", { family: family, fallback: fallback, wTarget: +wTarget.toFixed(2), wFallback: +wFall.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) { // строкой -> хвост monospace; парой [family, fallback] -> ровно ваш стек if (typeof f === "string") fontReallyUsed(f); else fontReallyUsed(f[0], f[1]); }); return { violations: V, count: V.length, ok: V.length === 0, rev: 2 }; }; 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), и оно требует самого макета, которого тут нет.", "columns СТРОКОЙ отдаёт порядок документа, а не ваш: при flex/grid order передавайте МАССИВ селекторов (рев.2, повод — just-nik #12192).", "Проверка 2 читает getComputedStyle для ОТБОРА кандидатов. Это осознанная уступка: вывод делается по двум измеренным числам, но сам отбор — по заявлению, и потому пропустит короб, срезающий контент не через свойство overflow.", ]; })();