Адаптивная верстка современных веб-приложений подвержена скрытым регрессиям на узких мобильных экранах (320px, 375px, 390px). В отличие от синтаксических ошибок JavaScript или падений API, визуальные поломки не вызывают исключений в рантайме:
scrollWidth > clientWidth), вызывая нежелательную горизонтальную прокрутку всей страницы.overflow: hidden при отсутствии свойства min-width: 0.Классические линтеры (ESLint, Stylelint) бессильны перед подобными дефектами, поскольку вычисление геометрии интерфейса происходит исключительно в фазе компоновки (Layout / Reflow) браузерного движка.
Архитектура автономного визуального самозалечивания (Autonomous Visual Layout Healing) реализует замкнутый контур обратной связи (Closed-Loop Feedback System):
boundingBox(), извлекается визуальный кроп-снимок и соответствующий фрагмент исходного кода компонента (JSX/TSX).Система функционирует по принципу динамического регулятора с обратной связью:
[ Headless Playwright (375px) ]
|
v
[ DOM Geometric Scan ] ──> (scrollWidth > clientWidth OR BoundingBox Overlap)
|
(Anomaly Detected)
|
v
[ Context Isolation ]
|-- Crop Screenshot (BoundingBox + 20px padding)
|-- Extract AST/JSX from Component Source File
`-- Extract Computed Styles (display, flex-shrink, min-width, overflow)
|
v
[ Multimodal Layout Healing Agent ]
`-- Prompt: Diagnosis + Tailwind CSS v4 Replacement Tokens
|
v
[ AST / Class Patch Application ]
|
v
[ Re-validation Pass (375px & 1440px) ]
|-- Check 1: Mobile Anomaly Resolved? (scrollWidth === clientWidth)
`-- Check 2: Desktop Layout Unbroken? (No Regression on 1440px)
|
(Success) ──> [ Create Git Commit / PR ]min-w-0, break-all, truncate или overflow-hidden для длинных идентификаторов, email-адресов и кодовых вставок.grid-cols-3 без адаптивных префиксов в grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 при падении ширины колонки ниже допустимого минимума.safe-area-inset-bottom) и переключение position: fixed на адаптивный поток при перекрытии контента мобильной клавиатурой или панелью действий.Ниже представлен модуль обнаружения геометрических дефектов и их автономного исправления с использованием Playwright, AST-манипуляций и Vercel AI SDK.
import { chromium, type Page, type Locator } from 'playwright';
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import * as fs from 'fs/promises';
import * as path from 'path';
// 1. Спецификация обнаруженного дефекта геометрии
export interface LayoutAnomaly {
selector: string;
anomalyType: 'HORIZONTAL_OVERFLOW' | 'COLLISION_OVERLAP' | 'ZERO_DIMENSION';
scrollWidth: number;
clientWidth: number;
boundingBox: { x: number; y: number; width: number; height: number };
computedStyles: {
display: string;
overflow: string;
flexShrink: string;
minWidth: string;
};
}
// 2. Схема патча от AI-агента
export const LayoutPatchSchema = z.object({
diagnosis: z.string().describe('Техническая причина дефекта компоновки'),
sourceFile: z.string().describe('Относительный путь к файлу компонента'),
originalClassString: z.string().describe('Исходная строка классов элемента, требующая замены'),
replacementClassString: z.string().describe('Исправленная строка классов Tailwind CSS v4'),
remediationRationale: z.string().describe('Обоснование влияния на другие брейкпоинты'),
});
export type LayoutPatch = z.infer<typeof LayoutPatchSchema>;
/**
* Сканирование страницы на наличие горизонтальных переполнений и сломанных flex-детей
*/
export async function detectLayoutAnomalies(page: Page): Promise<LayoutAnomaly[]> {
return await page.evaluate(() => {
const anomalies: LayoutAnomaly[] = [];
const elements = Array.from(document.querySelectorAll('*'));
const windowWidth = window.innerWidth;
for (const el of elements) {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
// Проверка горизонтального переполнения относительно окна или прямого контейнера
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
// Исключаем намеренные области с горизонтальным скроллом (например, карусели)
if (style.overflowX !== 'auto' && style.overflowX !== 'scroll') {
// Вычисление уникального CSS-селектора для локализации
const generateSelector = (node: Element): string => {
if (node.id) return `#${node.id}`;
const dataTestId = node.getAttribute('data-testid');
if (dataTestId) return `[data-testid="${dataTestId}"]`;
if (node.tagName === 'BODY') return 'body';
const parent = node.parentElement;
if (!parent) return node.tagName.toLowerCase();
const index = Array.from(parent.children).indexOf(node) + 1;
return `${generateSelector(parent)} > ${node.tagName.toLowerCase()}:nth-child(${index})`;
};
anomalies.push({
selector: generateSelector(el),
anomalyType: 'HORIZONTAL_OVERFLOW',
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
computedStyles: {
display: style.display,
overflow: style.overflow,
flexShrink: style.flexShrink,
minWidth: style.minWidth,
},
});
}
}
}
return anomalies;
});
}
/**
* Захват изолированного фрагмента скриншота с контекстным отступом
*/
export async function captureCroppedElement(page: Page, locator: Locator): Promise<Buffer> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Element is not visible or detached from DOM');
}
// Захват с запасом в 20px для контекста окружения
const padding = 20;
const clipRegion = {
x: Math.max(0, box.x - padding),
y: Math.max(0, box.y - padding),
width: box.width + padding * 2,
height: box.height + padding * 2,
};
return await page.screenshot({ clip: clipRegion });
}
/**
* Вызов Vision LLM для генерации точечного патча классов Tailwind CSS
*/
export async function generateLayoutHealingPatch(params: {
anomaly: LayoutAnomaly;
cropImage: Buffer;
sourceCodeSnippet: string;
sourceFilePath: string;
}): Promise<LayoutPatch> {
const imageBase64 = params.cropImage.toString('base64');
const systemPrompt = `
Вы — ведущий инженер по верстке и эксперт по CSS/Tailwind CSS v4.
Ваша цель — устранить дефект адаптивной компоновки на мобильном вьюпорте (375px), сохранив стабильность на десктопе.
Дефект локализован через Playwright: ${params.anomaly.anomalyType}.
scrollWidth: ${params.anomaly.scrollWidth}px, clientWidth: ${params.anomaly.clientWidth}px.
Правила модификации классов:
1. Используйте современные идиомы Tailwind CSS v4: 'min-w-0', 'break-words', 'overflow-hidden', 'shrink-0', 'flex-wrap'.
2. Не применяйте глобальные костыли вроде 'overflow-x: hidden' на body или корневом контейнере!
3. Изменение должно быть локальным: замените только строку 'className' проблемного JSX-элемента.
4. Если элемент сжимается внутри flex-контейнера, помните о правиле 'min-w-0'.
5. Ответ должен строго соответствовать переданной Zod-схеме.
`.trim();
const userContent = `
Файл компонента: ${params.sourceFilePath}
Текущие вычисленные стили: ${JSON.stringify(params.anomaly.computedStyles)}
Фрагмент исходного кода компонента:
\`\`\`tsx
${params.sourceCodeSnippet}
\`\`\`
Изучите прикрепленный кроп-скриншот элемента со сбоем и сгенерируйте точечный патч.
`.trim();
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: LayoutPatchSchema,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: userContent },
{ type: 'image', image: `data:image/png;base64,${imageBase64}` },
],
},
],
});
return object;
}
/**
* Замкнутый контур: обнаружение -> исправление -> повторный тест
*/
export async function runAutonomousLayoutHealing(params: {
url: string;
sourceFilesDir: string;
maxIterations?: number;
}): Promise<{ healed: boolean; appliedPatches: LayoutPatch[] }> {
const browser = await chromium.launch({ headless: true });
const appliedPatches: LayoutPatch[] = [];
const maxIterations = params.maxIterations ?? 3;
try {
for (let iteration = 1; iteration <= maxIterations; iteration++) {
const context = await browser.newContext({
viewport: { width: 375, height: 812 }, // iPhone 13/14/15 вьюпорт
});
const page = await context.newPage();
await page.goto(params.url, { waitUntil: 'networkidle' });
const anomalies = await detectLayoutAnomalies(page);
if (anomalies.length === 0) {
console.log(`✅ Итерация ${iteration}: дефектов верстки не обнаружено. Лейаут стабилен.`);
await context.close();
return { healed: true, appliedPatches };
}
const targetAnomaly = anomalies[0];
console.warn(`⚠️ Обнаружен дефект [${targetAnomaly.anomalyType}] на узле: ${targetAnomaly.selector}`);
const locator = page.locator(targetAnomaly.selector);
const cropImage = await captureCroppedElement(page, locator);
// Имитация сопоставления селектора с исходным файлом (через data-атрибуты или AST-поиск)
const targetFilePath = path.join(params.sourceFilesDir, 'UserProfileCard.tsx');
const fileContent = await fs.readFile(targetFilePath, 'utf-8');
const patch = await generateLayoutHealingPatch({
anomaly: targetAnomaly,
cropImage,
sourceCodeSnippet: fileContent,
sourceFilePath: targetFilePath,
});
// Применение патча к исходному файлу
if (fileContent.includes(patch.originalClassString)) {
const updatedContent = fileContent.replace(
patch.originalClassString,
patch.replacementClassString
);
await fs.writeFile(targetFilePath, updatedContent, 'utf-8');
appliedPatches.push(patch);
console.log(`🔧 Применен патч к ${patch.sourceFile}: ${patch.replacementClassString}`);
} else {
console.error('❌ Не удалось локализовать исходную строку классов в файле компонента');
}
await context.close();
}
return { healed: false, appliedPatches };
} finally {
await browser.close();
}
}Частая ошибка агентов без строгих ограничений — попытка починить переполнение добавлением overflow-x: hidden на тег <html> или <body>. Это визуально маскирует скроллбар, но не лечит причину: внутренний контент остается обрезанным, а дочерние элементы с position: sticky полностью перестают работать, ломая шапки и навигационные меню приложения.
Разработчики и AI-агенты часто добавляют класс truncate (или overflow-hidden text-ellipsis whitespace-nowrap) к тексту внутри Flex-контейнера, но текст продолжает распирать блок. По спецификации CSS Flexbox у flex-детей вычисленный min-width равен auto, а не 0. Без явного класса min-w-0 на родительском flex-элементе правило truncate игнорируется браузером.
Замена класса w-[400px] на w-full устраняет проблему на мобильном экране (375px), но растягивает форму на всю ширину ультраширокого монитора (1920px), нарушая дизайн. Любой фикс мобильной геометрии обязан использовать адаптивные префиксы Tailwind (w-full sm:w-[400px]) или контейнерные запросы (@container).
Генерация несуществующих классов (например, break-anywhere вместо break-words / break-all или смешивание синтаксиса Tailwind v3 с Tailwind v4). Валидация сгенерированного патча компилятором Tailwind перед записью в файл является обязательным условием надежности пайплайна.
Запуск Playwright в пресетах мобильных устройств (320px, 375px, 390px) с эмуляцией сенсорного экрана
Исполнение evaluate-скрипта для сравнения scrollWidth и clientWidth каждого узла в активном DOM-дереве
Извлечение точного boundingBox элемента, захват растрового кропа и локализация JSX-файла компонента
Генерация минимального патча классов Tailwind CSS с помощью Vision LLM с учетом Flex/Grid семантики
Перекомпиляция стилей и контрольный прогон на мобильном (375px) и десктопном (1440px) вьюпортах