bricks-builder-mcp 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/server.js +482 -222
package/README.md CHANGED
@@ -108,8 +108,14 @@ Claude Code détectera automatiquement le skill au prochain démarrage.
108
108
  **Éléments**
109
109
  - `find_elements`, `get_element`, `update_element` (avec param `label` pour renommer dans le builder), `add_element`, `batch_add`, `delete_element`, `reorder_sections`
110
110
 
111
- **⭐ Vérification visuelle**
112
- - `verify_element` — screenshot crop + report (computed style vs settings attendus, fonts chargées, erreurs JS console, etc.). **À utiliser après chaque batch_add significatif.**
111
+ **⭐ Vérification visuelle (v3.10+)**
112
+ - `verify_element` — screenshot crop + report. **À utiliser après chaque batch_add significatif.**
113
+ - Compare computed styles vs settings attendus, fonts, erreurs JS, débordement horizontal
114
+ - **v3.10 — Multi-viewport en 1 call** : `viewports: ["desktop", "mobile_portrait"]` → 1 report + 1 screenshot par viewport
115
+ - **v3.10 — Cohérence siblings** : text-align mixé entre frères, jumps font-size anormaux
116
+ - **v3.10 — Containers vides anormaux** : tout bloc ≥ 50×50px sans contenu (attrape les wrappers écrasés par `align-items: stretch`)
117
+ - **v3.10 — Santé des médias** : `naturalWidth > 0` sur les `<img>` (lazy-load cassé), `readyState ≥ 2` sur les `<video>`, alt présents
118
+ - Chaque check porte une `severity` (`critical` / `warning` / `info`) et un `hint` actionnable. Désactivable par catégorie via `checks: { sibling_coherence: false }`.
113
119
 
114
120
  **⭐ Upload optimisé (v3.8+)**
115
121
  - `upload_local_file`, `upload_local_files_batch` — l'IA donne juste le path local, le MCP server lit le fichier, conversion WebP automatique (-80 à -95% de poids), renommage SEO via le `title`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "bricks-builder-mcp",
4
- "version": "3.9.0",
4
+ "version": "3.10.0",
5
5
  "description": "Serveur MCP pour piloter Bricks Builder (WordPress) depuis Claude — édition de pages, gestion d'éléments, réordonnancement des sections, vérification visuelle (verify_element), upload optimisé WebP. Communauté Discord : https://discord.gg/rX22zHRzH",
6
6
  "homepage": "https://discord.gg/rX22zHRzH",
7
7
  "main": "server.js",
package/server.js CHANGED
@@ -214,6 +214,116 @@ function generateHint(prop, expected, got) {
214
214
  return null;
215
215
  }
216
216
 
217
+ // ===== v3.10 — Moteur de checks pluggable =====
218
+ // Chaque check est une fonction Node-side qui reçoit le `audit` collecté
219
+ // dans la page (siblings, media, emptyContainers) et retourne des entrées
220
+ // {ok, severity, label, hint, bbox} à pousser dans le report.
221
+ // Sévérités : 'critical' (bug bloquant), 'warning' (probablement un bug),
222
+ // 'info' (juste à savoir). 'ok' false = compte comme échec dans le score.
223
+
224
+ function buildSiblingCoherenceChecks(audit) {
225
+ const checks = [];
226
+ const siblings = audit.siblings || [];
227
+ if (siblings.length === 0) return checks;
228
+
229
+ const all = [audit.self, ...siblings];
230
+
231
+ // 1) text-align mixé entre frères directs
232
+ // Si un seul frère a un text-align différent, c'est souvent un bug visuel
233
+ // (ex: H2 left + sous-titre center sans intention design).
234
+ const aligns = [...new Set(all.map(s => s['text-align']).filter(Boolean))];
235
+ if (aligns.length > 1) {
236
+ checks.push({
237
+ ok: false,
238
+ severity: 'warning',
239
+ label: `text-align mixé entre frères directs (${aligns.join(', ')})`,
240
+ hint: "Frères directs avec text-align différents — souvent un bug visuel. Vérifie l'intention design ou aligne-les sur la même valeur.",
241
+ });
242
+ }
243
+
244
+ // 2) Jumps de font-size > 2.5x entre frères (info seulement, c'est parfois voulu pour H1/sous-titre)
245
+ const withFontSize = all.filter(s => s['font-size-px'] && s['font-size-px'] > 0);
246
+ if (withFontSize.length >= 2) {
247
+ const sizes = withFontSize.map(s => s['font-size-px']);
248
+ const min = Math.min(...sizes);
249
+ const max = Math.max(...sizes);
250
+ if (min > 0 && max / min > 2.5) {
251
+ checks.push({
252
+ ok: true,
253
+ severity: 'info',
254
+ label: `font-size variable entre frères (${Math.round(min)}px → ${Math.round(max)}px)`,
255
+ hint: "Écart > 2.5x entre frères — normal pour H1/sous-titre, à vérifier sinon.",
256
+ });
257
+ }
258
+ }
259
+
260
+ return checks;
261
+ }
262
+
263
+ function buildEmptyContainerChecks(audit) {
264
+ const checks = [];
265
+ const empties = audit.emptyContainers || [];
266
+ if (empties.length === 0) {
267
+ checks.push({ ok: true, label: "Aucun container vide anormal détecté (≥ 50×50px)" });
268
+ return checks;
269
+ }
270
+ const sorted = empties.slice().sort((a, b) => b.area - a.area).slice(0, 5);
271
+ checks.push({
272
+ ok: false,
273
+ severity: 'warning',
274
+ label: `${empties.length} container(s) visible(s) sans contenu (≥ 50×50px)`,
275
+ got: sorted.map(c => `${c.classes[0] || c.id || '?'} ${c.rect.w}×${c.rect.h}`),
276
+ bboxes: sorted.map(c => c.rect),
277
+ hint: "Conteneurs vides détectés — bug fréquent quand align-items: stretch écrase un block-vide à des proportions absurdes (dot 6px qui devient 180px, etc). Vérifie aspect-ratio fixe ou ajoute du contenu.",
278
+ });
279
+ return checks;
280
+ }
281
+
282
+ function buildMediaHealthChecks(audit) {
283
+ const checks = [];
284
+ const { images = [], videos = [] } = (audit.media || {});
285
+
286
+ if (images.length > 0) {
287
+ const broken = images.filter(i => !i.loaded);
288
+ if (broken.length === 0) {
289
+ checks.push({ ok: true, label: `${images.length} image(s) chargée(s) (naturalWidth > 0)` });
290
+ } else {
291
+ checks.push({
292
+ ok: false,
293
+ severity: 'critical',
294
+ label: `${broken.length}/${images.length} image(s) PAS chargée(s) (naturalWidth = 0)`,
295
+ got: broken.slice(0, 3).map(i => (i.src || '').split('/').pop() || '(no src)'),
296
+ hint: "Image(s) non chargée(s) — vérifie l'URL src, le lazy-loading qui ne se déclenche pas, ou un 404. À éviter en production.",
297
+ });
298
+ }
299
+ const noAlt = images.filter(i => !i.alt || i.alt.trim().length === 0);
300
+ if (noAlt.length > 0) {
301
+ checks.push({
302
+ ok: false,
303
+ severity: 'warning',
304
+ label: `${noAlt.length}/${images.length} image(s) sans attribut alt`,
305
+ hint: "alt manquant — bloque l'accessibilité et le SEO. Renseigne alt à l'upload via upload_local_file({alt: '...'}).",
306
+ });
307
+ }
308
+ }
309
+
310
+ if (videos.length > 0) {
311
+ const notLoaded = videos.filter(v => !v.loaded);
312
+ if (notLoaded.length === 0) {
313
+ checks.push({ ok: true, label: `${videos.length} vidéo(s) chargée(s) (readyState ≥ 2)` });
314
+ } else {
315
+ checks.push({
316
+ ok: false,
317
+ severity: 'warning',
318
+ label: `${notLoaded.length}/${videos.length} vidéo(s) pas prête(s) (readyState < 2)`,
319
+ hint: "Vidéo(s) non prête(s) — peut être normal si autoplay désactivé sur mobile. Sinon vérifier src/codec.",
320
+ });
321
+ }
322
+ }
323
+
324
+ return checks;
325
+ }
326
+
217
327
  // Configuration du fichier de log
218
328
  const __filename = fileURLToPath(import.meta.url);
219
329
  const __dirname = dirname(__filename);
@@ -999,7 +1109,7 @@ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
999
1109
  // ===== v3.6.0 — VERIFY ELEMENT (vérification visuelle + technique) =====
1000
1110
  {
1001
1111
  name: "verify_element",
1002
- description: "⭐ APRÈS CHAQUE batch_add OU update_element SIGNIFICATIF, utilise cet outil. Lance un browser headless, navigue sur la page, scroll vers l'élément, prend un screenshot crop et compare les computed styles avec les settings attendus. Retourne {screenshot (visible par Claude), report: {score, checks}, computed}. Tu vois ce que tu as fait. Force l'évolution petit-à-petit-vérifier-petit-à-petit.",
1112
+ description: "⭐ APRÈS CHAQUE batch_add OU update_element SIGNIFICATIF, utilise cet outil. Lance un browser headless, navigue sur la page, scroll vers l'élément, prend un screenshot crop et compare les computed styles avec les settings attendus. v3.10 : ajout de checks généralistes (cohérence siblings, containers vides, santé des médias) + multi-viewport en 1 call (param `viewports`). Retourne {screenshot(s) visibles par Claude, report: {score, checks}, computed}. Force l'évolution petit-à-petit-vérifier-petit-à-petit.",
1003
1113
  inputSchema: {
1004
1114
  type: "object",
1005
1115
  properties: {
@@ -1008,7 +1118,24 @@ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
1008
1118
  viewport: {
1009
1119
  type: "string",
1010
1120
  enum: ["desktop", "tablet", "mobile_landscape", "mobile_portrait"],
1011
- description: "Taille d'écran à tester (défaut: desktop)",
1121
+ description: "Taille d'écran à tester (défaut: desktop). Ignoré si `viewports` est fourni.",
1122
+ },
1123
+ viewports: {
1124
+ type: "array",
1125
+ items: { type: "string", enum: ["desktop", "tablet", "mobile_landscape", "mobile_portrait"] },
1126
+ description: "v3.10 : tester plusieurs viewports en 1 call. Renvoie un report + screenshot par viewport. Ex: ['desktop', 'mobile_portrait'].",
1127
+ },
1128
+ checks: {
1129
+ type: "object",
1130
+ description: "v3.10 : activer/désactiver des catégories de checks (toutes activées par défaut).",
1131
+ properties: {
1132
+ expected_styles: { type: "boolean", description: "Compare les computed styles aux settings attendus (défaut: true)" },
1133
+ sibling_coherence: { type: "boolean", description: "Détecte text-align mixé et jumps font-size entre frères directs (défaut: true)" },
1134
+ empty_containers: { type: "boolean", description: "Flag les blocs visibles ≥ 50×50px sans contenu (défaut: true)" },
1135
+ media_health: { type: "boolean", description: "Vérifie naturalWidth > 0 sur les <img>, readyState ≥ 2 sur les <video>, alt présents (défaut: true)" },
1136
+ overflow: { type: "boolean", description: "Détecte les débordements horizontaux (défaut: true)" },
1137
+ console_errors: { type: "boolean", description: "Remonte les erreurs JS console (défaut: true)" },
1138
+ },
1012
1139
  },
1013
1140
  },
1014
1141
  required: ["pageId", "elementId"],
@@ -1656,260 +1783,393 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
1656
1783
  result = await callWordPressAPI("/list-components", "GET");
1657
1784
  break;
1658
1785
 
1659
- // ===== v3.6.0 — VERIFY ELEMENT =====
1786
+ // ===== v3.10 — VERIFY ELEMENT (multi-viewport + checks pluggables) =====
1660
1787
  case "verify_element": {
1661
1788
  const pageId = args.pageId;
1662
1789
  const elementId = args.elementId;
1663
- const viewport = args.viewport || "desktop";
1790
+ // Backward compat : `viewport` (singulier) OU `viewports` (array)
1791
+ const viewportList = (Array.isArray(args.viewports) && args.viewports.length > 0)
1792
+ ? args.viewports
1793
+ : [args.viewport || "desktop"];
1794
+ const isMulti = viewportList.length > 1;
1795
+ const checksConfig = Object.assign({
1796
+ expected_styles: true,
1797
+ sibling_coherence: true,
1798
+ empty_containers: true,
1799
+ media_health: true,
1800
+ overflow: true,
1801
+ console_errors: true,
1802
+ }, args.checks || {});
1664
1803
  const viewportSizes = {
1665
1804
  desktop: { width: 1920, height: 1080 },
1666
1805
  tablet: { width: 991, height: 1200 },
1667
1806
  mobile_landscape: { width: 767, height: 600 },
1668
1807
  mobile_portrait: { width: 478, height: 800 },
1669
1808
  };
1670
- const viewportContext = viewportSizes[viewport] || viewportSizes.desktop;
1671
1809
 
1672
- // 1) Récupérer infos plugin (URL, sélecteur, expected styles)
1810
+ // 1) Récupérer infos plugin une seule fois (URL, sélecteur, expected styles)
1673
1811
  const info = await callWordPressAPI("/verify-element-info", "POST", { pageId, elementId });
1674
1812
 
1675
- // 2) Lancer browser headless
1676
- let page;
1677
- try {
1678
- page = await getNewPage(viewport);
1679
- } catch (browserErr) {
1680
- // Erreur Playwright (chromium pas installé etc) — retourner sans crasher
1681
- result = {
1682
- success: false,
1683
- error: browserErr.message,
1684
- url: info.url,
1685
- selector: info.selector,
1686
- hint: "Installation Chromium requise pour verify_element. À défaut, utilise screenshot-website-fast en MCP externe.",
1687
- };
1688
- break;
1689
- }
1690
-
1691
- // Collecte console errors
1692
- const consoleErrors = [];
1693
- page.on('console', msg => {
1694
- if (msg.type() === 'error') consoleErrors.push(msg.text());
1695
- });
1696
- page.on('pageerror', err => consoleErrors.push('PageError: ' + err.message));
1813
+ // 2) Boucler sur chaque viewport — chacun = 1 page, 1 screenshot, 1 report
1814
+ const perViewport = [];
1697
1815
 
1698
- let screenshotBase64 = null;
1699
- let report = { score: '0/0', checks: [] };
1700
- let computed = null;
1701
- let loadedFonts = [];
1816
+ for (const viewport of viewportList) {
1817
+ const viewportContext = viewportSizes[viewport] || viewportSizes.desktop;
1702
1818
 
1703
- try {
1704
- await page.goto(info.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1705
- // Attendre 2s pour Font Awesome / Google Fonts (règle d'or skill)
1706
- await page.waitForTimeout(2000);
1707
-
1708
- const element = await page.$(info.selector);
1709
- if (!element) {
1710
- // Diagnostic : qu'a-t-on réellement chargé ?
1711
- const diagnostics = await page.evaluate(() => ({
1712
- actualUrl: window.location.href,
1713
- title: document.title,
1714
- bodyClass: document.body?.className || '',
1715
- brxeCount: document.querySelectorAll('[class*="brxe-"]').length,
1716
- firstBrxe: document.querySelector('[class*="brxe-"]')?.className || null,
1717
- htmlSnippet: document.documentElement.outerHTML.substring(0, 500),
1718
- }));
1719
- result = {
1819
+ let page;
1820
+ try {
1821
+ page = await getNewPage(viewport);
1822
+ } catch (browserErr) {
1823
+ perViewport.push({
1824
+ viewport,
1720
1825
  success: false,
1721
- error: `Élément ${info.selector} introuvable dans le DOM`,
1722
- url: info.url,
1723
- selector: info.selector,
1724
- diagnostics,
1725
- hint: diagnostics.brxeCount === 0
1726
- ? "Aucune classe .brxe-* trouvée — la page n'a probablement pas chargé le contenu Bricks (cert SSL, redirect, 404, page d'erreur)"
1727
- : `${diagnostics.brxeCount} éléments .brxe-* trouvés mais pas ${info.selector} — l'ID dans la DB ne correspond pas au rendu`,
1728
- };
1729
- await page.close();
1730
- break;
1826
+ error: browserErr.message,
1827
+ hint: "Installation Chromium requise pour verify_element. À défaut, utilise screenshot-website-fast en MCP externe.",
1828
+ });
1829
+ continue;
1731
1830
  }
1732
1831
 
1733
- await element.scrollIntoViewIfNeeded();
1734
- await page.waitForTimeout(400);
1735
-
1736
- // Computed style + dimensions
1737
- computed = await element.evaluate(el => {
1738
- const cs = getComputedStyle(el);
1739
- const rect = el.getBoundingClientRect();
1740
- // Enfants "réels" = ceux qui ont une classe brxe-{id} ou id brxe-*
1741
- // Exclut les <video>, <picture> et autres ajoutés par Bricks (bg vidéo, overlay auto, etc.)
1742
- const realChildren = Array.from(el.children).filter(child => {
1743
- if (child.id && child.id.startsWith('brxe-')) return true;
1744
- return Array.from(child.classList).some(cls => cls.startsWith('brxe-'));
1745
- });
1746
- const bricksInternalChildren = Array.from(el.children).filter(child => {
1747
- return !((child.id && child.id.startsWith('brxe-')) ||
1748
- Array.from(child.classList).some(cls => cls.startsWith('brxe-')));
1749
- }).map(c => c.tagName.toLowerCase() + (c.className ? '.' + c.className.split(' ').join('.') : ''));
1750
- return {
1751
- display: cs.display,
1752
- 'flex-direction': cs.flexDirection,
1753
- 'justify-content': cs.justifyContent,
1754
- 'align-items': cs.alignItems,
1755
- gap: cs.gap,
1756
- 'column-gap': cs.columnGap,
1757
- 'row-gap': cs.rowGap,
1758
- width: Math.round(rect.width) + 'px',
1759
- height: Math.round(rect.height) + 'px',
1760
- 'max-width': cs.maxWidth,
1761
- 'padding-top': cs.paddingTop,
1762
- 'padding-right': cs.paddingRight,
1763
- 'padding-bottom': cs.paddingBottom,
1764
- 'padding-left': cs.paddingLeft,
1765
- 'margin-top': cs.marginTop,
1766
- 'margin-right': cs.marginRight,
1767
- 'margin-bottom': cs.marginBottom,
1768
- 'margin-left': cs.marginLeft,
1769
- 'background-color': cs.backgroundColor,
1770
- 'font-size': cs.fontSize,
1771
- 'font-family': cs.fontFamily,
1772
- 'font-weight': cs.fontWeight,
1773
- 'line-height': cs.lineHeight,
1774
- color: cs.color,
1775
- 'text-align': cs.textAlign,
1776
- 'border-top-left-radius': cs.borderTopLeftRadius,
1777
- 'border-top-right-radius': cs.borderTopRightRadius,
1778
- 'border-bottom-right-radius': cs.borderBottomRightRadius,
1779
- 'border-bottom-left-radius': cs.borderBottomLeftRadius,
1780
- visibility: cs.visibility,
1781
- opacity: cs.opacity,
1782
- childrenInDom: realChildren.length,
1783
- childrenTotalDom: el.children.length,
1784
- bricksInternalChildren: bricksInternalChildren,
1785
- isVisible: rect.width > 0 && rect.height > 0 && cs.visibility !== 'hidden' && cs.opacity !== '0',
1786
- hasOverflowX: el.scrollWidth > el.clientWidth,
1787
- };
1832
+ const consoleErrors = [];
1833
+ page.on('console', msg => {
1834
+ if (msg.type() === 'error') consoleErrors.push(msg.text());
1788
1835
  });
1836
+ page.on('pageerror', err => consoleErrors.push('PageError: ' + err.message));
1789
1837
 
1790
- // Fonts loaded
1791
- loadedFonts = await page.evaluate(() => {
1792
- const set = new Set();
1793
- document.fonts.forEach(f => { if (f.status === 'loaded') set.add(f.family); });
1794
- return Array.from(set);
1795
- });
1838
+ let screenshotBase64 = null;
1839
+ let audit = null;
1840
+ let loadedFonts = [];
1796
1841
 
1797
- // Build checks
1798
- const checks = [];
1799
- checks.push({ ok: true, label: `Élément trouvé dans le DOM (${info.selector})` });
1800
- checks.push({
1801
- ok: computed.isVisible,
1802
- label: `Élément visible (${computed.width} × ${computed.height})`,
1803
- ...(computed.isVisible ? {} : { hint: "Largeur ou hauteur à 0 — vérifie les enfants ou le padding du parent" })
1804
- });
1842
+ try {
1843
+ await page.goto(info.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1844
+ await page.waitForTimeout(2000);
1845
+
1846
+ const element = await page.$(info.selector);
1847
+ if (!element) {
1848
+ const diagnostics = await page.evaluate(() => ({
1849
+ actualUrl: window.location.href,
1850
+ title: document.title,
1851
+ bodyClass: document.body?.className || '',
1852
+ brxeCount: document.querySelectorAll('[class*="brxe-"]').length,
1853
+ firstBrxe: document.querySelector('[class*="brxe-"]')?.className || null,
1854
+ htmlSnippet: document.documentElement.outerHTML.substring(0, 500),
1855
+ }));
1856
+ perViewport.push({
1857
+ viewport,
1858
+ success: false,
1859
+ error: `Élément ${info.selector} introuvable dans le DOM`,
1860
+ diagnostics,
1861
+ hint: diagnostics.brxeCount === 0
1862
+ ? "Aucune classe .brxe-* trouvée — la page n'a probablement pas chargé le contenu Bricks (cert SSL, redirect, 404, page d'erreur)"
1863
+ : `${diagnostics.brxeCount} éléments .brxe-* trouvés mais pas ${info.selector} — l'ID dans la DB ne correspond pas au rendu`,
1864
+ });
1865
+ continue;
1866
+ }
1805
1867
 
1806
- // childrenCount expected vs réel
1807
- if (typeof info.childrenCount === 'number') {
1808
- const ok = info.childrenCount === computed.childrenInDom;
1809
- checks.push({
1810
- ok,
1811
- label: `${info.childrenCount} enfant(s) attendu(s) → ${computed.childrenInDom} dans le DOM`,
1812
- ...(ok ? {} : { expected: info.childrenCount, got: computed.childrenInDom })
1813
- });
1814
- }
1868
+ await element.scrollIntoViewIfNeeded();
1869
+ await page.waitForTimeout(400);
1870
+
1871
+ // === v3.10 : collecte enrichie en 1 evaluate (self + siblings + media + emptyContainers) ===
1872
+ audit = await page.evaluate((selector) => {
1873
+ const el = document.querySelector(selector);
1874
+ if (!el) return null;
1875
+ const cs = getComputedStyle(el);
1876
+ const rect = el.getBoundingClientRect();
1877
+
1878
+ const realChildren = Array.from(el.children).filter(child => {
1879
+ if (child.id && child.id.startsWith('brxe-')) return true;
1880
+ return Array.from(child.classList).some(cls => cls.startsWith('brxe-'));
1881
+ });
1882
+ const bricksInternalChildren = Array.from(el.children).filter(child => {
1883
+ return !((child.id && child.id.startsWith('brxe-')) ||
1884
+ Array.from(child.classList).some(cls => cls.startsWith('brxe-')));
1885
+ }).map(c => c.tagName.toLowerCase() + (c.className ? '.' + c.className.split(' ').filter(Boolean).join('.') : ''));
1886
+
1887
+ const computed = {
1888
+ display: cs.display,
1889
+ 'flex-direction': cs.flexDirection,
1890
+ 'justify-content': cs.justifyContent,
1891
+ 'align-items': cs.alignItems,
1892
+ gap: cs.gap,
1893
+ 'column-gap': cs.columnGap,
1894
+ 'row-gap': cs.rowGap,
1895
+ width: Math.round(rect.width) + 'px',
1896
+ height: Math.round(rect.height) + 'px',
1897
+ 'max-width': cs.maxWidth,
1898
+ 'padding-top': cs.paddingTop,
1899
+ 'padding-right': cs.paddingRight,
1900
+ 'padding-bottom': cs.paddingBottom,
1901
+ 'padding-left': cs.paddingLeft,
1902
+ 'margin-top': cs.marginTop,
1903
+ 'margin-right': cs.marginRight,
1904
+ 'margin-bottom': cs.marginBottom,
1905
+ 'margin-left': cs.marginLeft,
1906
+ 'background-color': cs.backgroundColor,
1907
+ 'font-size': cs.fontSize,
1908
+ 'font-family': cs.fontFamily,
1909
+ 'font-weight': cs.fontWeight,
1910
+ 'line-height': cs.lineHeight,
1911
+ color: cs.color,
1912
+ 'text-align': cs.textAlign,
1913
+ 'border-top-left-radius': cs.borderTopLeftRadius,
1914
+ 'border-top-right-radius': cs.borderTopRightRadius,
1915
+ 'border-bottom-right-radius': cs.borderBottomRightRadius,
1916
+ 'border-bottom-left-radius': cs.borderBottomLeftRadius,
1917
+ visibility: cs.visibility,
1918
+ opacity: cs.opacity,
1919
+ childrenInDom: realChildren.length,
1920
+ childrenTotalDom: el.children.length,
1921
+ bricksInternalChildren,
1922
+ isVisible: rect.width > 0 && rect.height > 0 && cs.visibility !== 'hidden' && cs.opacity !== '0',
1923
+ hasOverflowX: el.scrollWidth > el.clientWidth,
1924
+ };
1925
+
1926
+ // SIBLINGS (frères directs Bricks)
1927
+ const self = {
1928
+ id: el.id || '',
1929
+ 'text-align': cs.textAlign,
1930
+ 'font-size': cs.fontSize,
1931
+ 'font-size-px': parseFloat(cs.fontSize),
1932
+ };
1933
+ let siblings = [];
1934
+ if (el.parentElement) {
1935
+ const realSiblings = Array.from(el.parentElement.children).filter(s => {
1936
+ if (s === el) return false;
1937
+ if (s.id && s.id.startsWith('brxe-')) return true;
1938
+ return Array.from(s.classList).some(c => c.startsWith('brxe-'));
1939
+ });
1940
+ siblings = realSiblings.map(s => {
1941
+ const scs = getComputedStyle(s);
1942
+ const srect = s.getBoundingClientRect();
1943
+ return {
1944
+ id: s.id || '',
1945
+ classes: Array.from(s.classList).filter(c => c.startsWith('brxe-')),
1946
+ 'text-align': scs.textAlign,
1947
+ 'font-size': scs.fontSize,
1948
+ 'font-size-px': parseFloat(scs.fontSize),
1949
+ rect: { x: Math.round(srect.x), y: Math.round(srect.y), w: Math.round(srect.width), h: Math.round(srect.height) },
1950
+ };
1951
+ });
1952
+ }
1815
1953
 
1816
- // Compare chaque expected (viewport-aware pour vh/vw)
1817
- const expected = info.expected || {};
1818
- for (const [prop, expectedVal] of Object.entries(expected)) {
1819
- const got = computed[prop];
1820
- if (got === undefined) continue;
1821
- const ok = normaliseCssValue(got, viewportContext) === normaliseCssValue(expectedVal, viewportContext);
1822
- const check = { ok, label: `${prop} = ${expectedVal}` };
1823
- if (!ok) {
1824
- check.expected = expectedVal;
1825
- check.got = got;
1826
- const hint = generateHint(prop, expectedVal, got);
1827
- if (hint) check.hint = hint;
1828
- }
1829
- checks.push(check);
1830
- }
1954
+ // MEDIA (img + video à l'intérieur de l'élément)
1955
+ const images = Array.from(el.querySelectorAll('img')).map(img => ({
1956
+ src: img.currentSrc || img.src || '',
1957
+ naturalWidth: img.naturalWidth,
1958
+ naturalHeight: img.naturalHeight,
1959
+ alt: img.alt || '',
1960
+ loaded: img.naturalWidth > 0,
1961
+ loading: img.getAttribute('loading') || 'eager',
1962
+ }));
1963
+ const videos = Array.from(el.querySelectorAll('video')).map(v => ({
1964
+ src: v.currentSrc || v.src || '',
1965
+ readyState: v.readyState,
1966
+ paused: v.paused,
1967
+ loaded: v.readyState >= 2,
1968
+ }));
1969
+
1970
+ // EMPTY CONTAINERS (descendants Bricks visibles ≥ 50×50px sans contenu)
1971
+ const allBrxe = Array.from(el.querySelectorAll('[id^="brxe-"], [class*="brxe-"]'));
1972
+ const emptyContainers = [];
1973
+ const MIN_AREA = 2500;
1974
+ allBrxe.forEach(b => {
1975
+ const brect = b.getBoundingClientRect();
1976
+ if (brect.width < 50 || brect.height < 50) return;
1977
+ const area = brect.width * brect.height;
1978
+ if (area < MIN_AREA) return;
1979
+ const hasText = (b.textContent || '').trim().length > 0;
1980
+ const hasMedia = b.querySelector('img, picture, svg, video, iframe') !== null;
1981
+ const hasInteractive = b.querySelector('a, button, input, select, textarea') !== null;
1982
+ const bcs = getComputedStyle(b);
1983
+ const hasBgImg = bcs.backgroundImage && bcs.backgroundImage !== 'none';
1984
+ if (!hasText && !hasMedia && !hasInteractive && !hasBgImg) {
1985
+ emptyContainers.push({
1986
+ id: b.id || '',
1987
+ classes: Array.from(b.classList).filter(c => c.startsWith('brxe-')),
1988
+ rect: { x: Math.round(brect.x), y: Math.round(brect.y), w: Math.round(brect.width), h: Math.round(brect.height) },
1989
+ area: Math.round(area),
1990
+ });
1991
+ }
1992
+ });
1831
1993
 
1832
- // Console errors : séparer les vrais bugs JS des erreurs réseau (429/timeout)
1833
- // qui dépendent du serveur, pas du code de l'élément.
1834
- const realErrors = consoleErrors.filter(e =>
1835
- !e.includes('429') &&
1836
- !e.includes('net::ERR_') &&
1837
- !e.includes('Failed to load resource')
1838
- );
1839
- const networkErrors = consoleErrors.filter(e =>
1840
- e.includes('429') || e.includes('net::ERR_') || e.includes('Failed to load resource')
1841
- );
1842
-
1843
- if (realErrors.length > 0) {
1844
- checks.push({
1845
- ok: false,
1846
- label: `${realErrors.length} erreur(s) JS dans la console`,
1847
- got: realErrors.slice(0, 3),
1848
- hint: "Erreur JavaScript détectée — pas lié au serveur",
1849
- });
1850
- } else {
1851
- checks.push({ ok: true, label: "Aucune erreur JS console" });
1852
- }
1853
- // Erreurs réseau : info seulement, pas un échec
1854
- if (networkErrors.length > 0) {
1855
- checks.push({
1856
- ok: true,
1857
- label: `${networkErrors.length} erreur(s) réseau (429/load) — non bloquant`,
1858
- got: networkErrors.slice(0, 2),
1994
+ return { computed, self, siblings, media: { images, videos }, emptyContainers };
1995
+ }, info.selector);
1996
+
1997
+ // Fonts loaded
1998
+ loadedFonts = await page.evaluate(() => {
1999
+ const set = new Set();
2000
+ document.fonts.forEach(f => { if (f.status === 'loaded') set.add(f.family); });
2001
+ return Array.from(set);
1859
2002
  });
1860
- }
1861
2003
 
1862
- // Overflow horizontal
1863
- if (computed.hasOverflowX) {
2004
+ // === Construction des checks ===
2005
+ const checks = [];
2006
+ const computed = audit.computed;
2007
+ checks.push({ ok: true, label: `Élément trouvé dans le DOM (${info.selector})` });
1864
2008
  checks.push({
1865
- ok: false,
1866
- label: "Débordement horizontal détecté",
1867
- hint: "Un enfant dépasse la largeur du conteneur — vérifie les _widthMax et white-space",
2009
+ ok: computed.isVisible,
2010
+ label: `Élément visible (${computed.width} × ${computed.height})`,
2011
+ ...(computed.isVisible ? {} : { severity: 'critical', hint: "Largeur ou hauteur à 0 — vérifie les enfants ou le padding du parent" })
1868
2012
  });
1869
- }
1870
2013
 
1871
- // Screenshot crop (sur l'élément)
1872
- try {
1873
- const buf = await element.screenshot({ type: 'png' });
1874
- screenshotBase64 = buf.toString('base64');
1875
- } catch (e) {
1876
- // Si element invisible/hors viewport, fallback fullpage
1877
- const buf = await page.screenshot({ type: 'png', fullPage: false });
1878
- screenshotBase64 = buf.toString('base64');
1879
- }
2014
+ if (typeof info.childrenCount === 'number') {
2015
+ const ok = info.childrenCount === computed.childrenInDom;
2016
+ checks.push({
2017
+ ok,
2018
+ label: `${info.childrenCount} enfant(s) attendu(s) → ${computed.childrenInDom} dans le DOM`,
2019
+ ...(ok ? {} : { severity: 'warning', expected: info.childrenCount, got: computed.childrenInDom })
2020
+ });
2021
+ }
1880
2022
 
1881
- const okCount = checks.filter(c => c.ok).length;
1882
- report = { score: `${okCount}/${checks.length}`, checks };
2023
+ // Expected styles (viewport-aware vh/vw)
2024
+ if (checksConfig.expected_styles) {
2025
+ const expected = info.expected || {};
2026
+ for (const [prop, expectedVal] of Object.entries(expected)) {
2027
+ const got = computed[prop];
2028
+ if (got === undefined) continue;
2029
+ const ok = normaliseCssValue(got, viewportContext) === normaliseCssValue(expectedVal, viewportContext);
2030
+ const check = { ok, label: `${prop} = ${expectedVal}` };
2031
+ if (!ok) {
2032
+ check.severity = 'warning';
2033
+ check.expected = expectedVal;
2034
+ check.got = got;
2035
+ const hint = generateHint(prop, expectedVal, got);
2036
+ if (hint) check.hint = hint;
2037
+ }
2038
+ checks.push(check);
2039
+ }
2040
+ }
1883
2041
 
1884
- result = {
1885
- success: true,
1886
- url: info.url,
1887
- urlWithAnchor: info.urlWithAnchor,
1888
- selector: info.selector,
1889
- name: info.name,
1890
- label: info.label,
1891
- viewport,
1892
- report,
1893
- computed,
1894
- loadedFonts,
1895
- };
1896
- } finally {
1897
- if (page && !page.isClosed()) {
1898
- await page.close().catch(() => {});
2042
+ // v3.10 : cohérence siblings
2043
+ if (checksConfig.sibling_coherence) {
2044
+ checks.push(...buildSiblingCoherenceChecks(audit));
2045
+ }
2046
+ // v3.10 : containers vides
2047
+ if (checksConfig.empty_containers) {
2048
+ checks.push(...buildEmptyContainerChecks(audit));
2049
+ }
2050
+ // v3.10 : santé médias
2051
+ if (checksConfig.media_health) {
2052
+ checks.push(...buildMediaHealthChecks(audit));
2053
+ }
2054
+
2055
+ // Console errors : séparer JS vs réseau (429/timeout) — réseau = info, JS = échec
2056
+ if (checksConfig.console_errors) {
2057
+ const realErrors = consoleErrors.filter(e =>
2058
+ !e.includes('429') && !e.includes('net::ERR_') && !e.includes('Failed to load resource')
2059
+ );
2060
+ const networkErrors = consoleErrors.filter(e =>
2061
+ e.includes('429') || e.includes('net::ERR_') || e.includes('Failed to load resource')
2062
+ );
2063
+ if (realErrors.length > 0) {
2064
+ checks.push({
2065
+ ok: false,
2066
+ severity: 'critical',
2067
+ label: `${realErrors.length} erreur(s) JS dans la console`,
2068
+ got: realErrors.slice(0, 3),
2069
+ hint: "Erreur JavaScript détectée — pas lié au serveur",
2070
+ });
2071
+ } else {
2072
+ checks.push({ ok: true, label: "Aucune erreur JS console" });
2073
+ }
2074
+ if (networkErrors.length > 0) {
2075
+ checks.push({
2076
+ ok: true,
2077
+ severity: 'info',
2078
+ label: `${networkErrors.length} erreur(s) réseau (429/load) — non bloquant`,
2079
+ got: networkErrors.slice(0, 2),
2080
+ });
2081
+ }
2082
+ }
2083
+
2084
+ // Overflow horizontal
2085
+ if (checksConfig.overflow && computed.hasOverflowX) {
2086
+ checks.push({
2087
+ ok: false,
2088
+ severity: 'warning',
2089
+ label: "Débordement horizontal détecté",
2090
+ hint: "Un enfant dépasse la largeur du conteneur — vérifie les _widthMax et white-space",
2091
+ });
2092
+ }
2093
+
2094
+ // Screenshot crop (fallback fullpage si l'élément ne peut pas être capturé seul)
2095
+ try {
2096
+ const buf = await element.screenshot({ type: 'png' });
2097
+ screenshotBase64 = buf.toString('base64');
2098
+ } catch (e) {
2099
+ const buf = await page.screenshot({ type: 'png', fullPage: false });
2100
+ screenshotBase64 = buf.toString('base64');
2101
+ }
2102
+
2103
+ const okCount = checks.filter(c => c.ok).length;
2104
+ perViewport.push({
2105
+ viewport,
2106
+ success: true,
2107
+ report: { score: `${okCount}/${checks.length}`, checks },
2108
+ computed,
2109
+ loadedFonts,
2110
+ screenshotBase64,
2111
+ });
2112
+ } finally {
2113
+ if (page && !page.isClosed()) {
2114
+ await page.close().catch(() => {});
2115
+ }
1899
2116
  }
1900
2117
  }
1901
2118
 
1902
- // Réponse spéciale : ajouter l'image au content MCP pour que Claude la voie
1903
- const responseContent = [
1904
- { type: "text", text: JSON.stringify(result, null, 2) },
1905
- ];
1906
- if (screenshotBase64) {
1907
- responseContent.push({
1908
- type: "image",
1909
- data: screenshotBase64,
1910
- mimeType: "image/png",
1911
- });
2119
+ // 3) Construction de la réponse MCP
2120
+ const baseInfo = {
2121
+ url: info.url,
2122
+ urlWithAnchor: info.urlWithAnchor,
2123
+ selector: info.selector,
2124
+ name: info.name,
2125
+ label: info.label,
2126
+ };
2127
+ const responseContent = [];
2128
+
2129
+ if (isMulti) {
2130
+ // Multi-viewport : 1 JSON consolidé + 1 image par viewport (les b64 sont retirés du JSON)
2131
+ const summary = {
2132
+ ...baseInfo,
2133
+ viewports: perViewport.map(vr => {
2134
+ const { screenshotBase64: _drop, ...rest } = vr;
2135
+ return rest;
2136
+ }),
2137
+ };
2138
+ responseContent.push({ type: "text", text: JSON.stringify(summary, null, 2) });
2139
+ for (const vr of perViewport) {
2140
+ if (vr.screenshotBase64) {
2141
+ responseContent.push({ type: "image", data: vr.screenshotBase64, mimeType: "image/png" });
2142
+ }
2143
+ }
2144
+ } else {
2145
+ // Single viewport : structure plate (backward compat)
2146
+ const vr = perViewport[0];
2147
+ let flatResult;
2148
+ if (vr.success) {
2149
+ flatResult = {
2150
+ success: true,
2151
+ ...baseInfo,
2152
+ viewport: vr.viewport,
2153
+ report: vr.report,
2154
+ computed: vr.computed,
2155
+ loadedFonts: vr.loadedFonts,
2156
+ };
2157
+ } else {
2158
+ flatResult = {
2159
+ success: false,
2160
+ ...baseInfo,
2161
+ viewport: vr.viewport,
2162
+ error: vr.error,
2163
+ diagnostics: vr.diagnostics,
2164
+ hint: vr.hint,
2165
+ };
2166
+ }
2167
+ responseContent.push({ type: "text", text: JSON.stringify(flatResult, null, 2) });
2168
+ if (vr.screenshotBase64) {
2169
+ responseContent.push({ type: "image", data: vr.screenshotBase64, mimeType: "image/png" });
2170
+ }
1912
2171
  }
2172
+
1913
2173
  return { content: responseContent };
1914
2174
  }
1915
2175