dsh-desktop-kit 0.0.0-stage → 2.0.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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -2
  3. package/client.js +4101 -0
  4. package/cordis.patch.yml +5 -0
  5. package/docs/ARCHITECTURE.md +114 -0
  6. package/docs/COMPATIBILITY.md +61 -0
  7. package/docs/INSTALL.md +84 -0
  8. package/docs/PRIVACY.md +50 -0
  9. package/docs/ROADMAP.md +57 -0
  10. package/docs/TOKENS.md +118 -0
  11. package/docs/TROUBLESHOOTING.md +68 -0
  12. package/docs/images/hero.png +0 -0
  13. package/index.js +84 -0
  14. package/package.json +70 -4
  15. package/plugins/dsh-gkb-ru-ui/cordis.patch.yml +6 -0
  16. package/plugins/dsh-gkb-ru-ui/lib/client.js +1331 -0
  17. package/plugins/dsh-gkb-ru-ui/lib/index.js +186 -0
  18. package/plugins/dsh-gkb-ru-ui/lib/skills.js +570 -0
  19. package/plugins/dsh-gkb-ru-ui/package.json +28 -0
  20. package/plugins/dsh-gkb-ru-ui/screenshots/01-home-tiles-1440-light.png +0 -0
  21. package/plugins/dsh-gkb-ru-ui/screenshots/02-skills-1440-light.png +0 -0
  22. package/plugins/dsh-gkb-ru-ui/screenshots/03-skills-editor-1440-light.png +0 -0
  23. package/plugins/dsh-gkb-ru-ui/screenshots/04-skills-1440-dark.png +0 -0
  24. package/plugins/dsh-gkb-ru-ui/screenshots/05-skills-390-light.png +0 -0
  25. package/plugins/dsh-gkb-ru-ui/screenshots/06-home-tiles-1440-dark.png +0 -0
  26. package/plugins/dsh-peak-guard/README.md +70 -0
  27. package/plugins/dsh-peak-guard/client.js +741 -0
  28. package/plugins/dsh-peak-guard/cordis.patch.yml +5 -0
  29. package/plugins/dsh-peak-guard/icon.svg +12 -0
  30. package/plugins/dsh-peak-guard/index.js +2 -0
  31. package/plugins/dsh-peak-guard/locale/en.json +6 -0
  32. package/plugins/dsh-peak-guard/locale/ru.json +6 -0
  33. package/plugins/dsh-peak-guard/locale/zh.json +6 -0
  34. package/plugins/dsh-peak-guard/package.json +33 -0
  35. package/plugins/dsh-peak-guard/screenshots/01-real-time-peak-start.png +0 -0
  36. package/plugins/dsh-peak-guard/screenshots/02-real-time-peak-end.png +0 -0
  37. package/plugins/dsh-peak-guard/screenshots/03-peak-card-1440-light.png +0 -0
  38. package/plugins/dsh-peak-guard/screenshots/04-peak-card-1440-dark.png +0 -0
  39. package/plugins/dsh-peak-guard/screenshots/05-peak-card-390-light.png +0 -0
  40. package/plugins/dsh-peak-guard/screenshots/05-peak-card-768-light.png +0 -0
  41. package/plugins/dsh-peak-guard/screenshots/06-peak-card-390-light.png +0 -0
  42. package/plugins/dsh-peak-guard/screenshots/07-live-preview-check.png +0 -0
  43. package/plugins/dsh-peak-guard/screenshots/07-pre-peak-1440-light.png +0 -0
  44. package/plugins/dsh-peak-guard/screenshots/08-peak-start-1440-light.png +0 -0
  45. package/plugins/dsh-peak-guard/screenshots/09-peak-end-1440-light.png +0 -0
  46. package/plugins/dsh-peak-guard/screenshots/10-pre-peak-card-1440-light.png +0 -0
  47. package/plugins/dsh-peak-guard/screenshots/11-pre-peak-card-1440-dark.png +0 -0
  48. package/plugins/dsh-peak-guard/screenshots/12-pre-peak-card-390-light.png +0 -0
  49. package/plugins/dsh-peak-guard/screenshots/qa-report.json +74 -0
  50. package/plugins/dsh-profile-transfer/README.md +62 -0
  51. package/plugins/dsh-profile-transfer/bootstrap/01-INSTALL.cmd +7 -0
  52. package/plugins/dsh-profile-transfer/bootstrap/README-ARCHIVE.md +54 -0
  53. package/plugins/dsh-profile-transfer/bootstrap/README-/320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/232/320/220.md +42 -0
  54. package/plugins/dsh-profile-transfer/bootstrap/install-on-clean-machine.ps1 +119 -0
  55. package/plugins/dsh-profile-transfer/bootstrap//320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/230/320/242/320/254.cmd +5 -0
  56. package/plugins/dsh-profile-transfer/client.js +806 -0
  57. package/plugins/dsh-profile-transfer/cordis.patch.yml +3 -0
  58. package/plugins/dsh-profile-transfer/icon.svg +13 -0
  59. package/plugins/dsh-profile-transfer/index.js +375 -0
  60. package/plugins/dsh-profile-transfer/locale/en.json +6 -0
  61. package/plugins/dsh-profile-transfer/locale/ru.json +6 -0
  62. package/plugins/dsh-profile-transfer/locale/zh.json +6 -0
  63. package/plugins/dsh-profile-transfer/package.json +36 -0
  64. package/plugins/dsh-profile-transfer/screenshots/01-settings-transfer-1440-light.png +0 -0
  65. package/plugins/dsh-profile-transfer/screenshots/02-settings-transfer-preview-1440-light.png +0 -0
  66. package/plugins/dsh-profile-transfer/screenshots/03-settings-transfer-1440-dark.png +0 -0
  67. package/plugins/dsh-profile-transfer/screenshots/04-settings-transfer-390-light.png +0 -0
  68. package/plugins/dsh-profile-transfer/screenshots/05-settings-transfer-768-light.png +0 -0
  69. package/plugins/dsh-profile-transfer/screenshots/06-settings-transfer-390-dark.png +0 -0
  70. package/plugins/dsh-profile-transfer/screenshots/07-settings-transfer-export-result.png +0 -0
  71. package/plugins/dsh-profile-transfer/screenshots/08-settings-transfer-workspaces-1440-light.png +0 -0
  72. package/plugins/dsh-profile-transfer/screenshots/09-settings-transfer-workspaces-1440-dark.png +0 -0
  73. package/plugins/dsh-profile-transfer/screenshots/10-settings-transfer-workspaces-390-light.png +0 -0
  74. package/plugins/dsh-profile-transfer/transfer.js +1004 -0
  75. package/plugins/dsh-project-memory/README.md +71 -0
  76. package/plugins/dsh-project-memory/cordis.patch.yml +4 -0
  77. package/plugins/dsh-project-memory/icon.svg +12 -0
  78. package/plugins/dsh-project-memory/index.js +1240 -0
  79. package/plugins/dsh-project-memory/locale/en.json +6 -0
  80. package/plugins/dsh-project-memory/locale/ru.json +6 -0
  81. package/plugins/dsh-project-memory/locale/zh.json +6 -0
  82. package/plugins/dsh-project-memory/package.json +26 -0
  83. package/plugins/dsh-session-move/README.md +67 -0
  84. package/plugins/dsh-session-move/client.js +719 -0
  85. package/plugins/dsh-session-move/cordis.patch.yml +5 -0
  86. package/plugins/dsh-session-move/icon.svg +14 -0
  87. package/plugins/dsh-session-move/index.js +497 -0
  88. package/plugins/dsh-session-move/locale/en.json +6 -0
  89. package/plugins/dsh-session-move/locale/ru.json +6 -0
  90. package/plugins/dsh-session-move/locale/zh.json +6 -0
  91. package/plugins/dsh-session-move/package.json +31 -0
  92. package/plugins/dsh-session-move/screenshots/01-row-menu-1440-light.png +0 -0
  93. package/plugins/dsh-session-move/screenshots/02-dialog-1440-light.png +0 -0
  94. package/plugins/dsh-session-move/screenshots/03-dialog-390-light.png +0 -0
  95. package/plugins/dsh-session-move/screenshots/04-dialog-1440-dark.png +0 -0
  96. package/plugins/dsh-session-move/screenshots/05-drag-highlight-1440-light.png +0 -0
  97. package/plugins/dsh-session-move/screenshots/06-drop-ungrouped-highlight.png +0 -0
  98. package/plugins/dsh-session-move/screenshots/07-after-drop-ungrouped.png +0 -0
  99. package/plugins/dsh-session-move/screenshots/08-move-dialog.png +0 -0
  100. package/plugins/dsh-session-move/screenshots/09-moved-to-second-workspace.png +0 -0
  101. package/plugins/dsh-session-move/screenshots/qa-report.json +62 -0
  102. package/plugins/dsh-skill-autopilot/cordis.patch.yml +3 -0
  103. package/plugins/dsh-skill-autopilot/index.js +444 -0
  104. package/plugins/dsh-skill-autopilot/package.json +19 -0
  105. package/plugins/dsh-token-lab/cordis.patch.yml +3 -0
  106. package/plugins/dsh-token-lab/lib/client.js +504 -0
  107. package/plugins/dsh-token-lab/lib/index.js +275 -0
  108. package/plugins/dsh-token-lab/package.json +26 -0
  109. package/plugins/dsh-token-lab/screenshots/01-token-lab-1440-light.png +0 -0
  110. package/plugins/dsh-token-lab/screenshots/02-token-lab-1440-dark.png +0 -0
  111. package/skills/code-review-checklist/SKILL.md +55 -0
  112. package/skills/content-writing/README.md +87 -0
  113. package/skills/content-writing/SKILL.md +85 -0
  114. package/skills/content-writing/assets/checklists/fact-check.md +53 -0
  115. package/skills/content-writing/assets/checklists/publish.md +65 -0
  116. package/skills/content-writing/assets/checklists/social.md +40 -0
  117. package/skills/content-writing/assets/templates/article.md +47 -0
  118. package/skills/content-writing/assets/templates/brief.md +44 -0
  119. package/skills/content-writing/assets/templates/doctor-card.md +41 -0
  120. package/skills/content-writing/assets/templates/image-prompts.md +128 -0
  121. package/skills/content-writing/assets/templates/post-telegram.md +36 -0
  122. package/skills/content-writing/assets/templates/post-vk.md +42 -0
  123. package/skills/content-writing/assets/templates/press-release.md +56 -0
  124. package/skills/content-writing/assets/templates/science-pop-note.md +55 -0
  125. package/skills/content-writing/assets/templates/service-card.md +46 -0
  126. package/skills/content-writing/references/anti-ai-style.md +248 -0
  127. package/skills/content-writing/references/editing.md +185 -0
  128. package/skills/content-writing/references/fact-checking.md +205 -0
  129. package/skills/content-writing/references/government-medical.md +246 -0
  130. package/skills/content-writing/references/images.md +212 -0
  131. package/skills/content-writing/references/journalism.md +252 -0
  132. package/skills/content-writing/references/process.md +123 -0
  133. package/skills/content-writing/references/quick-recipes.md +99 -0
  134. package/skills/content-writing/references/rules.md +85 -0
  135. package/skills/content-writing/references/science-popular.md +218 -0
  136. package/skills/content-writing/references/seo-content.md +172 -0
  137. package/skills/content-writing/references/social-media.md +253 -0
  138. package/skills/content-writing/references/sources.md +49 -0
  139. package/skills/content-writing/references/templates.md +291 -0
  140. package/skills/context-economy/SKILL.md +60 -0
  141. package/skills/context-economy/references/long-session.md +35 -0
  142. package/skills/context-economy/references/token-math.md +42 -0
  143. package/skills/context-economy/references/tool-recipes.md +34 -0
  144. package/skills/database-safety/SKILL.md +47 -0
  145. package/skills/debugging-playbook/SKILL.md +42 -0
  146. package/skills/design-critique/SKILL.md +65 -0
  147. package/skills/design-critique/references/a11y.md +25 -0
  148. package/skills/design-critique/references/diagnoses.md +33 -0
  149. package/skills/design-critique/references/quick-tests.md +30 -0
  150. package/skills/design-critique/references/report-template.md +63 -0
  151. package/skills/design-critique/references/rubric-fast.md +73 -0
  152. package/skills/dev-workflow/SKILL.md +48 -0
  153. package/skills/dev-workflow/references/definition-of-done.md +44 -0
  154. package/skills/dev-workflow/references/memory-and-boundaries.md +31 -0
  155. package/skills/dev-workflow/references/stages.md +67 -0
  156. package/skills/git-and-ci/SKILL.md +60 -0
  157. package/skills/pg-upgrade-verify/SKILL.md +67 -0
  158. package/skills/project-deep-dive/SKILL.md +45 -0
  159. package/skills/project-deep-dive/references/recon-commands.md +90 -0
  160. package/skills/project-deep-dive/references/section-templates.md +104 -0
  161. package/skills/project-deep-dive/references/tool-and-process.md +86 -0
  162. package/skills/project-memory-maintenance/SKILL.md +55 -0
  163. package/skills/project-memory-maintenance/references/api-and-checklist.md +99 -0
  164. package/skills/project-memory-maintenance/references/journal-template.md +50 -0
  165. package/skills/project-memory-maintenance/references/section-rules.md +59 -0
  166. package/skills/release-and-deploy/SKILL.md +53 -0
  167. package/skills/requirements-and-planning/SKILL.md +49 -0
  168. package/skills/requirements-and-planning/references/antipatterns-and-checklist.md +20 -0
  169. package/skills/requirements-and-planning/references/process-steps.md +52 -0
  170. package/skills/requirements-and-planning/references/templates-and-estimates.md +29 -0
  171. package/skills/security-review/SKILL.md +40 -0
  172. package/skills/seo/README.md +60 -0
  173. package/skills/seo/SKILL.md +77 -0
  174. package/skills/seo/assets/README.md +66 -0
  175. package/skills/seo/assets/scripts/LICENSE-playwright-core.txt +202 -0
  176. package/skills/seo/assets/scripts/README.md +179 -0
  177. package/skills/seo/assets/scripts/lib/args.mjs +105 -0
  178. package/skills/seo/assets/scripts/lib/browser.mjs +620 -0
  179. package/skills/seo/assets/scripts/lib/zip.mjs +191 -0
  180. package/skills/seo/assets/scripts/seo-audit.mjs +1675 -0
  181. package/skills/seo/assets/snippets/jsonld-breadcrumb.json +30 -0
  182. package/skills/seo/assets/snippets/jsonld-faq.json +39 -0
  183. package/skills/seo/assets/snippets/jsonld-medical-clinic.json +49 -0
  184. package/skills/seo/assets/snippets/jsonld-medical-webpage.json +33 -0
  185. package/skills/seo/assets/snippets/jsonld-organization.json +74 -0
  186. package/skills/seo/assets/snippets/jsonld-physician.json +46 -0
  187. package/skills/seo/assets/snippets/meta.blade.php +61 -0
  188. package/skills/seo/assets/snippets/robots.txt +61 -0
  189. package/skills/seo/assets/snippets/sitemap-urlset-example.xml +72 -0
  190. package/skills/seo/assets/snippets/sitemap.xml +36 -0
  191. package/skills/seo/references/antipatterns.md +66 -0
  192. package/skills/seo/references/audit-checklist.md +319 -0
  193. package/skills/seo/references/content-seo.md +204 -0
  194. package/skills/seo/references/eeat-medical.md +219 -0
  195. package/skills/seo/references/measurement.md +250 -0
  196. package/skills/seo/references/on-page.md +214 -0
  197. package/skills/seo/references/page-templates.md +56 -0
  198. package/skills/seo/references/stack-recipes.md +695 -0
  199. package/skills/seo/references/structured-data.md +487 -0
  200. package/skills/seo/references/technical-seo.md +296 -0
  201. package/skills/service-desk-conventions/SKILL.md +52 -0
  202. package/skills/service-desk-db-safety/SKILL.md +63 -0
  203. package/skills/service-desk-release/SKILL.md +61 -0
  204. package/skills/service-desk-testing/SKILL.md +61 -0
  205. package/skills/service-desk-ui/SKILL.md +52 -0
  206. package/skills/skills-router/SKILL.md +53 -0
  207. package/skills/skills-router/references/applying-skills.md +25 -0
  208. package/skills/skills-router/references/routing-matrix.md +52 -0
  209. package/skills/testing-strategy/SKILL.md +52 -0
  210. package/skills/ui-design-system/SKILL.md +50 -0
  211. package/skills/ui-design-system/references/accessibility-and-responsive.md +50 -0
  212. package/skills/ui-design-system/references/tokens-and-components.md +67 -0
  213. package/skills/ui-design-system/references/visual-qa.md +44 -0
  214. package/skills/web-research/SKILL.md +47 -0
  215. package/skills/web-research/references/answer-and-citations.md +60 -0
  216. package/skills/web-research/references/query-recipes.md +44 -0
  217. package/skills/wow-design/SKILL.md +89 -0
  218. package/skills/wow-design/assets/kit/README.md +195 -0
  219. package/skills/wow-design/assets/kit/assets/css/base.css +158 -0
  220. package/skills/wow-design/assets/kit/assets/css/components.css +1001 -0
  221. package/skills/wow-design/assets/kit/assets/css/fonts.css +73 -0
  222. package/skills/wow-design/assets/kit/assets/css/pages.css +737 -0
  223. package/skills/wow-design/assets/kit/assets/css/tokens.css +218 -0
  224. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-golos-text.txt +93 -0
  225. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-lora.txt +93 -0
  226. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-unbounded.txt +93 -0
  227. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-400-normal.woff2 +0 -0
  228. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-500-normal.woff2 +0 -0
  229. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-600-normal.woff2 +0 -0
  230. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-700-normal.woff2 +0 -0
  231. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-400-normal.woff2 +0 -0
  232. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-500-normal.woff2 +0 -0
  233. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-600-normal.woff2 +0 -0
  234. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-700-normal.woff2 +0 -0
  235. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-italic.woff2 +0 -0
  236. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-normal.woff2 +0 -0
  237. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-500-normal.woff2 +0 -0
  238. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-600-normal.woff2 +0 -0
  239. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-400-normal.woff2 +0 -0
  240. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-500-normal.woff2 +0 -0
  241. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-600-normal.woff2 +0 -0
  242. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-400-normal.woff2 +0 -0
  243. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-500-normal.woff2 +0 -0
  244. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-600-normal.woff2 +0 -0
  245. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-400-normal.woff2 +0 -0
  246. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-500-normal.woff2 +0 -0
  247. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-600-normal.woff2 +0 -0
  248. package/skills/wow-design/assets/kit/assets/js/kit.js +550 -0
  249. package/skills/wow-design/assets/kit/pages/dashboard.html +539 -0
  250. package/skills/wow-design/assets/kit/pages/hospital.html +430 -0
  251. package/skills/wow-design/assets/kit/showcase.html +1164 -0
  252. package/skills/wow-design/assets/scripts/LICENSE-axe-core.txt +362 -0
  253. package/skills/wow-design/assets/scripts/LICENSE-playwright-core.txt +202 -0
  254. package/skills/wow-design/assets/scripts/README.md +312 -0
  255. package/skills/wow-design/assets/scripts/audit.mjs +1200 -0
  256. package/skills/wow-design/assets/scripts/capture.mjs +337 -0
  257. package/skills/wow-design/assets/scripts/package.json +30 -0
  258. package/skills/wow-design/references/anti-patterns.md +252 -0
  259. package/skills/wow-design/references/critique-rubric.md +262 -0
  260. package/skills/wow-design/references/design-brief-template.md +48 -0
  261. package/skills/wow-design/references/inspiration.md +203 -0
  262. package/skills/wow-design/references/presets.md +99 -0
  263. package/skills/wow-design/references/principles.md +371 -0
  264. package/skills/wow-design/references/quick-presets.md +50 -0
  265. package/skills/wow-design/references/report-template.md +21 -0
  266. package/skills/wow-design/references/stack-recipes.md +96 -0
  267. package/skills/wow-design/references/visual-qa.md +82 -0
  268. package/skills/wow-design/references/wow-levers.md +43 -0
  269. package/tools/skill-cost.mjs +76 -0
@@ -0,0 +1,1200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit.mjs — доступность и технический аудит страницы:
4
+ * - axe-core (инжектится из node_modules/axe-core/axe.min.js);
5
+ * - кастомные проверки: горизонтальный overflow на 390px, tap-targets < 40px,
6
+ * порядок заголовков, alt у img, битые изображения, пустые ссылки/кнопки,
7
+ * загрузка шрифтов, ошибки консоли/pageerror, сбои загрузки ресурсов.
8
+ *
9
+ * Usage:
10
+ * node audit.mjs <путь|url> [--out <dir>] [--name <name>] [--json]
11
+ * [--themes light,dark] [--preset aurora] [--serve] [--cdp <url>]
12
+ *
13
+ * Пишет audit.json + audit.md в --out (по умолчанию tmp/qa-artifacts).
14
+ * Коды выхода: 0 — нет critical/serious; 2 — есть critical/serious; 1 — сбой инструмента.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { parseArgs, safeFileName, UsageError, printHelpAndExit } from './lib/args.mjs';
21
+ import {
22
+ connectChrome,
23
+ disposeChrome,
24
+ resolveTarget,
25
+ startStaticServer,
26
+ openPage,
27
+ closePage,
28
+ gotoPage,
29
+ wireDiagnostics,
30
+ addThemeInitScript,
31
+ preparePage,
32
+ waitForFonts,
33
+ parseThemeList,
34
+ VIEWPORTS,
35
+ } from './lib/browser.mjs';
36
+
37
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
38
+ const WORKSPACE = path.resolve(HERE, '..', '..');
39
+ const DEFAULT_OUT = path.join(WORKSPACE, 'tmp', 'qa-artifacts');
40
+ const AXE_PATH = path.join(HERE, 'node_modules', 'axe-core', 'axe.min.js');
41
+ const AXE_PKG = path.join(HERE, 'node_modules', 'axe-core', 'package.json');
42
+
43
+ const SEVERITY = ['critical', 'serious', 'moderate', 'minor'];
44
+ const SEVERITY_RANK = { critical: 4, serious: 3, moderate: 2, minor: 1 };
45
+
46
+ const FIX_HINTS = {
47
+ 'color-contrast': 'Поднять контраст текста: обычный текст ≥ 4.5:1, крупный (≥24px/18.6px bold) ≥ 3:1. Проверить светлую И тёмную тему.',
48
+ 'image-alt': 'Добавить alt: описательный — для смысловых изображений, alt="" — для декоративных.',
49
+ 'button-name': 'Дать кнопке доступное имя: видимый текст, aria-label или aria-labelledby.',
50
+ 'link-name': 'Добавить текст ссылке или aria-label (иконка-ссылка без имени недопустима).',
51
+ label: 'Связать поле с <label for="..."> (или aria-label/aria-labelledby).',
52
+ 'html-has-lang': 'Указать lang="ru" на <html>.',
53
+ 'html-lang-valid': 'Использовать корректный код языка: lang="ru".',
54
+ 'heading-order': 'Выстроить иерархию h1 → h2 → h3 без пропуска уровней.',
55
+ 'page-has-heading-one': 'Добавить один h1 (главный заголовок страницы).',
56
+ 'landmark-one-main': 'Обернуть основное содержимое в <main>.',
57
+ region: 'Весь контент — внутри лендмарков: header / nav / main / footer.',
58
+ 'duplicate-id': 'Убрать дублирующиеся id (на них ломаются aria-ссылки и якоря).',
59
+ 'duplicate-id-aria': 'Убрать дубли id, на которые ссылается ARIA.',
60
+ 'target-size': 'Увеличить интерактивные цели: минимум 24×24px, для мобильного — 40×40px.',
61
+ 'label-content-name-mismatch': 'Видимый текст должен входить в доступное имя: aria-label = видимый текст + контекст (для свотчей включите hex).',
62
+ 'aria-hidden-focus': 'Не скрывать через aria-hidden фокусируемые элементы.',
63
+ 'aria-required-attr': 'Добавить обязательные ARIA-атрибуты роли.',
64
+ 'aria-valid-attr-value': 'Исправить значения ARIA-атрибутов.',
65
+ 'aria-allowed-attr': 'Убрать ARIA-атрибуты, недопустимые для роли.',
66
+ 'aria-prohibited-attr': 'Убрать запрещённые ARIA-атрибуты.',
67
+ listitem: 'Элементы li допустимы только внутри ul/ol.',
68
+ list: 'ul/ol должны содержать только li.',
69
+ 'select-name': 'Дать select доступное имя (label/aria-label).',
70
+ 'input-button-name': 'Дать input[type=button/submit] доступное имя (value/aria-label).',
71
+ 'frame-title': 'Добавить title для iframe.',
72
+ 'svg-img-alt': 'Дать SVG с role="img" доступное имя (title/aria-label).',
73
+ 'scrollable-region-focusable': 'Скролл-контейнеру добавить tabindex="0", чтобы он был доступен с клавиатуры.',
74
+ 'nested-interactive': 'Убрать вложенные интерактивные элементы (кнопка внутри ссылки и т.п.).',
75
+ 'meta-viewport': 'Не блокировать масштабирование (не ставить user-scalable=no, maximum-scale<5).',
76
+ 'overflow-horizontal': 'Убрать фиксированные ширины/отрицательные margin/100vw с padding; таблицы обернуть в overflow-x:auto.',
77
+ 'tap-target-small': 'Best practice: увеличить интерактивные цели до ≥40×40px (WCAG 2.5.8 обязательный минимум — 24×24px).',
78
+ 'tap-target-tiny': 'Блокер WCAG 2.5.8: цель меньше 24×24px. Увеличить кнопку/иконку (padding) или кликабельную область ссылки.',
79
+ 'image-broken': 'Исправить/убрать битое изображение (проверить путь и формат).',
80
+ 'heading-skipped': 'Не пропускать уровни заголовков (после h2 — h3, а не h4).',
81
+ 'heading-multiple-h1': 'Оставить ровно один h1 на страницу.',
82
+ 'heading-none': 'Добавить h1 — главный заголовок страницы.',
83
+ 'link-empty-name': 'Добавить ссылке текст или aria-label.',
84
+ 'button-empty-name': 'Добавить кнопке текст или aria-label.',
85
+ 'font-not-loaded': 'Дождаться/исправить загрузку веб-шрифтов: document.fonts.status должен быть "loaded".',
86
+ 'font-face-error': 'Проверить путь/формат файла шрифта (status "error" у @font-face).',
87
+ 'console-error': 'Исправить ошибки JS в консоли.',
88
+ 'page-error': 'Исправить необработанное исключение JS (страница ломается).',
89
+ 'request-failed': 'Исправить загрузку ресурса (404/битый путь/блокировка CORS).',
90
+ };
91
+
92
+ const HELP = `
93
+ audit.mjs — axe-аудит + технические проверки страницы (overflow, tap-targets, заголовки, alt,
94
+ шрифты, консоль). Пишет audit.json и audit.md.
95
+
96
+ Использование:
97
+ node tools/design-qa/audit.mjs <путь-к-html|url> [--out <dir>] [--name <name>] [--json]
98
+
99
+ Опции:
100
+ --out <dir> Каталог для audit.json / audit.md (по умолчанию tmp/qa-artifacts)
101
+ --name <name> Префикс отчётов: <name>-audit.json / <name>-audit.md
102
+ --themes <list> light,dark (по умолчанию обе)
103
+ --preset <id> Выставить data-preset на <html>
104
+ --serve Отдавать страницу по http://127.0.0.1 (для ES-модулей)
105
+ --cdp <url> CDP-эндпоинт (по умолчанию env CDP_URL или http://127.0.0.1:9333)
106
+ --timeout <ms> Таймаут загрузки (по умолчанию 30000)
107
+ --json Полный JSON в stdout (логи — в stderr)
108
+ -h, --help Эта справка
109
+
110
+ Проверки: axe-core (AA + best practice), overflow на 390px, tap-targets (WCAG 2.5.8: <24×24 —
111
+ блокер, 24–39px — best practice, инлайновые ссылки в тексте/навигации не учитываются), порядок
112
+ заголовков, alt/битые изображения, пустые ссылки/кнопки, шрифты (document.fonts.status),
113
+ ошибки консоли/pageerror, сбои загрузки ресурсов. Контраст — через axe (обе темы).
114
+
115
+ Коды выхода: 0 — нет critical/serious; 2 — есть critical/serious; 1 — сбой инструмента.
116
+ `;
117
+
118
+ const CUSTOM_CHECKS = () => {
119
+ const doc = document;
120
+ const de = doc.documentElement;
121
+ const vw = window.innerWidth || 0;
122
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
123
+ const trunc = (s, n) => {
124
+ const t = norm(s);
125
+ return t.length > n ? t.slice(0, n) + '…' : t;
126
+ };
127
+ const selectorOf = (el) => {
128
+ if (!el || el.nodeType !== 1) return '?';
129
+ const parts = [];
130
+ let cur = el;
131
+ let depth = 0;
132
+ while (cur && cur.nodeType === 1 && depth++ < 6) {
133
+ let part = cur.tagName.toLowerCase();
134
+ if (cur.id) {
135
+ parts.unshift(`${part}#${cur.id}`);
136
+ break;
137
+ }
138
+ const cls = cur.classList ? Array.from(cur.classList).filter((c) => c.length < 40).slice(0, 2) : [];
139
+ if (cls.length) part += '.' + cls.join('.');
140
+ const parent = cur.parentElement;
141
+ if (parent) {
142
+ const idx = Array.from(parent.children).indexOf(cur) + 1;
143
+ if (idx > 1 || !cur.id) part += `:nth-child(${idx})`;
144
+ }
145
+ parts.unshift(part);
146
+ cur = cur.parentElement;
147
+ }
148
+ return parts.join(' > ');
149
+ };
150
+ const isVisible = (el) => {
151
+ if (!el || el.nodeType !== 1) return false;
152
+ const r = el.getBoundingClientRect();
153
+ const cs = getComputedStyle(el);
154
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
155
+ if (Number(cs.opacity) === 0) {
156
+ // opacity:0 — типичный reveal-блок; считаем видимым, если участвует в layout
157
+ return r.width > 0 && r.height > 0;
158
+ }
159
+ return r.width > 0 || r.height > 0;
160
+ };
161
+ const rectOf = (el) => el.getBoundingClientRect();
162
+ // Кликабельная область: label расширяет цель для checkbox/radio.
163
+ const clickableRectOf = (el) => {
164
+ if (el.tagName === 'INPUT') {
165
+ const type = String(el.type || '').toLowerCase();
166
+ if (type === 'checkbox' || type === 'radio') {
167
+ const label =
168
+ el.closest('label') ||
169
+ (el.id ? doc.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null);
170
+ if (label) return label.getBoundingClientRect();
171
+ }
172
+ }
173
+ return el.getBoundingClientRect();
174
+ };
175
+ const withinScroller = (el) => {
176
+ let p = el.parentElement;
177
+ while (p && p !== doc.body) {
178
+ const cs = getComputedStyle(p);
179
+ if (/(auto|scroll)/.test(cs.overflowX)) return true;
180
+ p = p.parentElement;
181
+ }
182
+ return false;
183
+ };
184
+ const accName = (el) => {
185
+ const aria = el.getAttribute && el.getAttribute('aria-label');
186
+ if (aria && aria.trim()) return aria.trim();
187
+ const by = el.getAttribute && el.getAttribute('aria-labelledby');
188
+ if (by) {
189
+ const t = by
190
+ .split(/\s+/)
191
+ .map((id) => doc.getElementById(id)?.textContent || '')
192
+ .join(' ')
193
+ .trim();
194
+ if (t) return t;
195
+ }
196
+ const title = el.getAttribute && el.getAttribute('title');
197
+ if (title && title.trim()) return title.trim();
198
+ const txt = (el.innerText || el.textContent || '').trim();
199
+ if (txt) return txt;
200
+ const img = el.querySelector && el.querySelector('img[alt]');
201
+ if (img && img.alt && img.alt.trim()) return img.alt.trim();
202
+ const svgTitle = el.querySelector && el.querySelector('svg title');
203
+ if (svgTitle) return norm(svgTitle.textContent);
204
+ const value = el.getAttribute && el.getAttribute('value');
205
+ if (value && value.trim()) return value.trim();
206
+ return '';
207
+ };
208
+
209
+ // ---------- overflow ----------
210
+ const clientWidth = de ? de.clientWidth : 0;
211
+ const scrollWidth = Math.max(de?.scrollWidth || 0, doc.body?.scrollWidth || 0, doc.scrollingElement?.scrollWidth || 0);
212
+ const bodyOverflowX = doc.body ? getComputedStyle(doc.body).overflowX : null;
213
+ const offenders = [];
214
+ if (scrollWidth > clientWidth + 1) {
215
+ for (const el of doc.querySelectorAll('body *')) {
216
+ if (offenders.length >= 15) break;
217
+ const r = rectOf(el);
218
+ if (r.width <= 0 || r.height <= 0) continue;
219
+ if (!(r.right > clientWidth + 1 || r.left < -1)) continue;
220
+ if (el.tagName === 'HTML' || el.tagName === 'BODY') continue;
221
+ if (withinScroller(el)) continue;
222
+ offenders.push({
223
+ tag: el.tagName.toLowerCase(),
224
+ selector: selectorOf(el),
225
+ left: Math.round(r.left),
226
+ right: Math.round(r.right),
227
+ width: Math.round(r.width),
228
+ text: trunc(el.textContent, 100),
229
+ });
230
+ }
231
+ }
232
+
233
+ // ---------- tap targets ----------
234
+ const INTERACTIVE =
235
+ 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="tab"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [contenteditable="true"]';
236
+ const tapTargets = [];
237
+ const tapTargetsAdvisory = [];
238
+ // WCAG 2.5.8: инлайновые ссылки в тексте/навигации — не цели (advisory, не влияет на вердикт).
239
+ const isInlineContextLink = (el) => {
240
+ if (el.tagName !== 'A') return false;
241
+ const cs = getComputedStyle(el);
242
+ if (cs.display === 'inline') return true;
243
+ let p = el.parentElement;
244
+ while (p && p !== doc.body) {
245
+ const tag = p.tagName.toLowerCase();
246
+ if (tag === 'p' || tag === 'li' || tag === 'nav') return true;
247
+ const cls = typeof p.className === 'string' ? p.className : '';
248
+ if (/breadcrumb/i.test(cls)) return true;
249
+ p = p.parentElement;
250
+ }
251
+ return false;
252
+ };
253
+ for (const el of doc.querySelectorAll(INTERACTIVE)) {
254
+ if (tapTargets.length >= 60) break;
255
+ if (!isVisible(el)) continue;
256
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
257
+ const cs = getComputedStyle(el);
258
+ if (cs.pointerEvents === 'none') continue;
259
+ const tag = el.tagName.toLowerCase();
260
+ const role = el.getAttribute('role');
261
+ let kind = 'control';
262
+ if (tag === 'a' || role === 'link') kind = 'link';
263
+ else if (tag === 'button' || tag === 'summary' || role === 'button') kind = 'button';
264
+ const r = clickableRectOf(el);
265
+ const width = Math.round(r.width);
266
+ const height = Math.round(r.height);
267
+ if (width <= 0 || height <= 0) continue;
268
+ if (width >= 40 && height >= 40) continue;
269
+ const acc = accName(el);
270
+ const record = {
271
+ tag,
272
+ kind,
273
+ selector: selectorOf(el),
274
+ text: trunc(acc, 100),
275
+ hasText: norm(el.textContent).length > 0,
276
+ width,
277
+ height,
278
+ };
279
+ if (kind === 'link' && isInlineContextLink(el)) {
280
+ if (tapTargetsAdvisory.length < 20) tapTargetsAdvisory.push({ ...record, reason: 'inline-link' });
281
+ continue;
282
+ }
283
+ tapTargets.push(record);
284
+ }
285
+
286
+ // ---------- headings ----------
287
+ const headings = [];
288
+ for (const h of doc.querySelectorAll('h1,h2,h3,h4,h5,h6')) {
289
+ if (!isVisible(h)) continue;
290
+ headings.push({
291
+ level: Number(h.tagName[1]),
292
+ text: trunc(h.textContent, 120),
293
+ selector: selectorOf(h),
294
+ });
295
+ }
296
+ const headingIssues = [];
297
+ const h1s = headings.filter((h) => h.level === 1);
298
+ if (!headingIssues.length && h1s.length === 0) {
299
+ headingIssues.push({
300
+ type: 'heading-none',
301
+ message: 'На странице нет h1',
302
+ severity: 'moderate',
303
+ nodes: [],
304
+ });
305
+ }
306
+ if (h1s.length > 1) {
307
+ headingIssues.push({
308
+ type: 'heading-multiple-h1',
309
+ message: `Несколько h1 на странице: ${h1s.length}`,
310
+ severity: 'moderate',
311
+ nodes: h1s.map((h) => ({ target: h.selector, html: h.text })),
312
+ });
313
+ }
314
+ for (let i = 1; i < headings.length; i++) {
315
+ const prev = headings[i - 1];
316
+ const cur = headings[i];
317
+ if (cur.level - prev.level > 1) {
318
+ headingIssues.push({
319
+ type: 'heading-skipped',
320
+ message: `Пропуск уровня: h${prev.level} → h${cur.level}`,
321
+ severity: 'serious',
322
+ nodes: [
323
+ { target: prev.selector, html: `h${prev.level}: ${prev.text}` },
324
+ { target: cur.selector, html: `h${cur.level}: ${cur.text}` },
325
+ ],
326
+ });
327
+ if (headingIssues.length >= 20) break;
328
+ }
329
+ }
330
+
331
+ // ---------- images ----------
332
+ const images = [];
333
+ for (const img of Array.from(doc.images)) {
334
+ images.push({
335
+ selector: selectorOf(img),
336
+ src: (img.currentSrc || img.src || '').slice(0, 200),
337
+ hasAlt: img.hasAttribute('alt'),
338
+ alt: img.getAttribute('alt'),
339
+ complete: img.complete,
340
+ naturalWidth: img.naturalWidth,
341
+ visible: isVisible(img),
342
+ });
343
+ }
344
+ const imagesMissingAlt = images.filter((i) => !i.hasAlt);
345
+ const imagesBroken = images.filter((i) => i.complete && i.naturalWidth === 0);
346
+ const imagesPending = images.filter((i) => !i.complete);
347
+
348
+ // ---------- links & buttons без доступного имени ----------
349
+ const nameIssues = [];
350
+ for (const a of doc.querySelectorAll('a[href]')) {
351
+ if (!isVisible(a)) continue;
352
+ if (accName(a)) continue;
353
+ nameIssues.push({
354
+ type: 'link-empty-name',
355
+ message: 'Ссылка без доступного имени',
356
+ severity: 'serious',
357
+ nodes: [{ target: selectorOf(a), html: trunc(a.outerHTML, 200) }],
358
+ });
359
+ if (nameIssues.length >= 25) break;
360
+ }
361
+ for (const b of doc.querySelectorAll('button, [role="button"]')) {
362
+ if (!isVisible(b)) continue;
363
+ if (accName(b)) continue;
364
+ nameIssues.push({
365
+ type: 'button-empty-name',
366
+ message: 'Кнопка без доступного имени',
367
+ severity: 'serious',
368
+ nodes: [{ target: selectorOf(b), html: trunc(b.outerHTML, 200) }],
369
+ });
370
+ if (nameIssues.length >= 25) break;
371
+ }
372
+
373
+ // ---------- шрифты ----------
374
+ const fonts = { status: null, size: 0, faces: [] };
375
+ try {
376
+ fonts.status = doc.fonts.status;
377
+ fonts.size = doc.fonts.size;
378
+ doc.fonts.forEach((f) =>
379
+ fonts.faces.push({ family: f.family, weight: f.weight, style: f.style, status: f.status }),
380
+ );
381
+ } catch {
382
+ /* ignore */
383
+ }
384
+
385
+ return {
386
+ viewportWidth: vw,
387
+ clientWidth,
388
+ scrollWidth,
389
+ bodyOverflowX,
390
+ overflow: { overflowing: scrollWidth > clientWidth + 1, offenders },
391
+ tapTargets,
392
+ tapTargetsAdvisory,
393
+ headings,
394
+ headingIssues,
395
+ images: {
396
+ total: images.length,
397
+ missingAlt: imagesMissingAlt,
398
+ broken: imagesBroken,
399
+ pending: imagesPending,
400
+ },
401
+ nameIssues,
402
+ fonts,
403
+ };
404
+ };
405
+
406
+ async function main() {
407
+ const args = parseArgs(process.argv.slice(2), {
408
+ booleans: ['help', 'json', 'serve', 'motion', 'quiet'],
409
+ values: ['out', 'name', 'themes', 'preset', 'serve-root', 'cdp', 'timeout'],
410
+ aliases: { h: 'help' },
411
+ defaults: {
412
+ help: false,
413
+ json: false,
414
+ serve: false,
415
+ motion: false,
416
+ quiet: false,
417
+ out: undefined,
418
+ name: undefined,
419
+ themes: 'light,dark',
420
+ preset: undefined,
421
+ cdp: undefined,
422
+ timeout: '30000',
423
+ },
424
+ });
425
+ if (args.help) printHelpAndExit(HELP);
426
+ const log = args.json ? (...a) => console.error(...a) : (...a) => console.log(...a);
427
+
428
+ const target = args._[0];
429
+ if (!target) throw new UsageError('Не указан путь к HTML-файлу или URL (см. --help)');
430
+ if (!fs.existsSync(AXE_PATH)) {
431
+ throw new Error(`Не найден axe-core: ${AXE_PATH}. Установите: npm i axe-core --cache <...> (в tools/design-qa)`);
432
+ }
433
+ const axeSource = fs.readFileSync(AXE_PATH, 'utf8');
434
+ const axeVersion = readJsonSafe(AXE_PKG)?.version || 'unknown';
435
+
436
+ const themes = parseThemeList(args.themes);
437
+ const preset = args.preset ? String(args.preset) : null;
438
+ const timeout = Number(args.timeout) || 30000;
439
+ const resolved = resolveTarget(String(target), { serve: args.serve });
440
+ const name = safeFileName(args.name || defaultName(resolved), 'page');
441
+ const outDir = path.resolve(process.cwd(), args.out || DEFAULT_OUT);
442
+ fs.mkdirSync(outDir, { recursive: true });
443
+ const files = args.name
444
+ ? { json: path.join(outDir, `${name}-audit.json`), md: path.join(outDir, `${name}-audit.md`) }
445
+ : { json: path.join(outDir, 'audit.json'), md: path.join(outDir, 'audit.md') };
446
+
447
+ const startedAt = new Date();
448
+ const t0 = Date.now();
449
+ const viewports = [
450
+ { id: 'desktop', ...VIEWPORTS.desktop },
451
+ { id: 'mobile', ...VIEWPORTS.mobile },
452
+ ];
453
+
454
+ const result = {
455
+ tool: 'design-qa/audit',
456
+ schemaVersion: 1,
457
+ name,
458
+ source: resolved.source,
459
+ url: resolved.url,
460
+ startedAt: startedAt.toISOString(),
461
+ finishedAt: null,
462
+ durationMs: null,
463
+ browser: null,
464
+ options: { themes, viewports, preset, axeVersion, reducedMotion: args.motion ? 'no-preference' : 'reduce' },
465
+ summary: null,
466
+ issues: [],
467
+ axe: [],
468
+ custom: [],
469
+ console: { consoleErrors: [], pageErrors: [], failedRequests: [] },
470
+ fonts: { status: 'unknown', faces: [] },
471
+ fatalError: null,
472
+ exitCode: 0,
473
+ };
474
+
475
+ let conn = null;
476
+ let server = null;
477
+ try {
478
+ conn = await connectChrome({ cdpUrl: args.cdp || undefined, log });
479
+ result.browser = {
480
+ endpoint: conn.endpoint,
481
+ launched: conn.launched,
482
+ browser: conn.version?.Browser || conn.version?.browser || null,
483
+ };
484
+ log(`[audit] ${resolved.source}`);
485
+ log(`[audit] браузер: ${result.browser.browser || '?'} (${conn.launched ? 'запущен fallback' : 'CDP ' + conn.endpoint}); axe-core ${axeVersion}`);
486
+
487
+ if (args.serve) {
488
+ if (!resolved.abs) throw new UsageError('--serve поддерживается только для локальных html-файлов');
489
+ server = await startStaticServer(process.cwd());
490
+ const rel = path.relative(process.cwd(), resolved.abs).split(path.sep).join('/');
491
+ resolved.url = `${server.origin}/${rel}`;
492
+ result.url = resolved.url;
493
+ }
494
+
495
+ const allFonts = [];
496
+ for (const theme of themes) {
497
+ for (const vp of viewports) {
498
+ const tag = `${theme}/${vp.id}`;
499
+ const load = { theme, viewport: vp.id, width: vp.width, height: vp.height };
500
+ log(`[audit] ${tag}: загрузка...`);
501
+ try {
502
+ const handle = await openPage(conn.browser, {
503
+ viewport: vp,
504
+ dpr: 1,
505
+ theme,
506
+ reducedMotion: args.motion ? 'no-preference' : 'reduce',
507
+ });
508
+ const page = handle.page;
509
+ const diag = wireDiagnostics(page);
510
+ try {
511
+ await addThemeInitScript(page, { theme, preset });
512
+ await gotoPage(page, resolved.url, { timeout });
513
+ await preparePage(page, { theme, preset, animations: true, scroll: true });
514
+ const fonts = await waitForFonts(page);
515
+ allFonts.push({ tag, ...fonts });
516
+ // Снимок диагностики ДО инжекта axe: axe-core сам делает XHR к CSS (на file://
517
+ // это CORS-ошибки), и без снимка они бы приписывались странице.
518
+ const diagBeforeAxe = {
519
+ consoleErrors: diag.consoleErrors.slice(),
520
+ pageErrors: diag.pageErrors.slice(),
521
+ failedRequests: diag.failedRequests.slice(),
522
+ };
523
+
524
+ // axe-core
525
+ await page.addScriptTag({ content: axeSource });
526
+ const axeRaw = await page.evaluate(async () => {
527
+ try {
528
+ return await window.axe.run(document, {
529
+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] },
530
+ resultTypes: ['violations', 'incomplete'],
531
+ });
532
+ } catch (e) {
533
+ return { error: String(e && e.message ? e.message : e) };
534
+ }
535
+ });
536
+ const axe = trimAxeResult(axeRaw);
537
+ result.axe.push({ theme, viewport: vp.id, ...axe });
538
+
539
+ // кастомные проверки
540
+ const custom = await page.evaluate(CUSTOM_CHECKS);
541
+ result.custom.push({ theme, viewport: vp.id, ...trimCustom(custom) });
542
+ result.console.consoleErrors.push(...diagBeforeAxe.consoleErrors.map((e) => ({ tag, ...e })));
543
+ result.console.pageErrors.push(...diagBeforeAxe.pageErrors.map((e) => ({ tag, ...e })));
544
+ result.console.failedRequests.push(...diagBeforeAxe.failedRequests.map((e) => ({ tag, ...e })));
545
+ } finally {
546
+ await closePage(handle);
547
+ }
548
+ } catch (err) {
549
+ const message = `${err?.name || 'Error'}: ${err?.message || err}`;
550
+ log(`[audit] ✖ ${tag}: ${message}`);
551
+ result.custom.push({ theme, viewport: vp.id, error: message });
552
+ }
553
+ }
554
+ }
555
+ result.fonts = mergeFonts(allFonts);
556
+ } catch (err) {
557
+ result.fatalError = `${err?.name || 'Error'}: ${err?.message || err}`;
558
+ log(`[audit] ОШИБКА: ${result.fatalError}`);
559
+ result.exitCode = 1;
560
+ } finally {
561
+ if (server) await server.close().catch(() => {});
562
+ if (conn) await disposeChrome(conn);
563
+ }
564
+
565
+ result.finishedAt = new Date().toISOString();
566
+ result.durationMs = Date.now() - t0;
567
+
568
+ // ---------- агрегируем issues ----------
569
+ const issues = [];
570
+ for (const axeLoad of result.axe) {
571
+ for (const v of axeLoad.violations || []) {
572
+ issues.push({
573
+ source: 'axe',
574
+ id: v.id,
575
+ severity: normalizeSeverity(v.impact),
576
+ theme: axeLoad.theme,
577
+ viewport: axeLoad.viewport,
578
+ description: v.description,
579
+ help: v.help,
580
+ helpUrl: v.helpUrl,
581
+ tags: v.tags,
582
+ nodes: v.nodes,
583
+ nodeCount: v.nodeCount,
584
+ fix: FIX_HINTS[v.id] || v.help,
585
+ });
586
+ }
587
+ }
588
+ for (const load of result.custom) {
589
+ if (!load.overflow && !load.tapTargets) continue;
590
+ const where = { theme: load.theme, viewport: load.viewport };
591
+ if (load.overflow && load.overflow.overflowing) {
592
+ issues.push({
593
+ source: 'custom',
594
+ id: 'overflow-horizontal',
595
+ severity: 'critical',
596
+ ...where,
597
+ help: `Горизонтальный overflow: scrollWidth ${load.overflow.scrollWidth} > clientWidth ${load.overflow.clientWidth}px`,
598
+ fix: FIX_HINTS['overflow-horizontal'],
599
+ nodes: load.overflow.offenders.map((o) => ({
600
+ target: o.selector,
601
+ html: o.text,
602
+ extra: `${o.width}px, ${o.left}..${o.right}`,
603
+ })),
604
+ nodeCount: load.overflow.offenders.length || 1,
605
+ });
606
+ }
607
+ const taps = load.tapTargets || [];
608
+ if (taps.length) {
609
+ // WCAG 2.5.8: <24×24 у иконочных целей — блокер; 24–39 — best practice (minor).
610
+ // Нативные контролы (checkbox/radio/select) и текстовые ссылки малой высоты —
611
+ // spacing/UA-исключения: только minor, чтобы не блокировать витрины.
612
+ const isTiny = (t) => t.width < 24 || t.height < 24;
613
+ const tinyBlocking = taps.filter((t) => isTiny(t) && t.kind !== 'control' && !(t.kind === 'link' && t.hasText));
614
+ const minor = taps.filter((t) => !tinyBlocking.includes(t));
615
+ if (tinyBlocking.length) {
616
+ issues.push({
617
+ source: 'custom',
618
+ id: 'tap-target-tiny',
619
+ severity: 'critical',
620
+ ...where,
621
+ help: `Иконочные цели меньше 24×24px: ${tinyBlocking.length} (WCAG 2.5.8, viewport ${load.viewport})`,
622
+ fix: FIX_HINTS['tap-target-tiny'],
623
+ nodes: tinyBlocking.slice(0, 25).map((t) => ({
624
+ target: t.selector,
625
+ html: `${t.tag} ${t.width}×${t.height}px «${t.text}»`,
626
+ extra: `${t.width}×${t.height}`,
627
+ })),
628
+ nodeCount: tinyBlocking.length,
629
+ });
630
+ }
631
+ if (minor.length) {
632
+ issues.push({
633
+ source: 'custom',
634
+ id: 'tap-target-small',
635
+ severity: 'minor',
636
+ ...where,
637
+ help: `Цели 24–39px или текстовые ссылки малой высоты (best practice, не блокер): ${minor.length} (viewport ${load.viewport})`,
638
+ fix: FIX_HINTS['tap-target-small'],
639
+ nodes: minor.slice(0, 25).map((t) => ({
640
+ target: t.selector,
641
+ html: `${t.tag} ${t.width}×${t.height}px «${t.text}»`,
642
+ extra: `${t.width}×${t.height}`,
643
+ })),
644
+ nodeCount: minor.length,
645
+ });
646
+ }
647
+ }
648
+ for (const h of load.headingIssues || []) {
649
+ issues.push({
650
+ source: 'custom',
651
+ id: h.type,
652
+ severity: h.severity,
653
+ theme: load.theme,
654
+ viewport: load.viewport,
655
+ help: h.message,
656
+ fix: FIX_HINTS[h.type] || FIX_HINTS['heading-order'],
657
+ nodes: h.nodes || [],
658
+ nodeCount: (h.nodes || []).length || 1,
659
+ });
660
+ }
661
+ for (const img of load.images?.missingAlt || []) {
662
+ issues.push({
663
+ source: 'custom',
664
+ id: 'image-alt-missing',
665
+ severity: 'serious',
666
+ theme: load.theme,
667
+ viewport: load.viewport,
668
+ help: 'У <img> нет атрибута alt',
669
+ fix: FIX_HINTS['image-alt'],
670
+ nodes: [{ target: img.selector, html: img.src }],
671
+ nodeCount: 1,
672
+ });
673
+ }
674
+ for (const img of load.images?.broken || []) {
675
+ issues.push({
676
+ source: 'custom',
677
+ id: 'image-broken',
678
+ severity: 'serious',
679
+ theme: load.theme,
680
+ viewport: load.viewport,
681
+ help: 'Изображение не загрузилось (naturalWidth = 0)',
682
+ fix: FIX_HINTS['image-broken'],
683
+ nodes: [{ target: img.selector, html: img.src }],
684
+ nodeCount: 1,
685
+ });
686
+ }
687
+ for (const ni of load.nameIssues || []) {
688
+ issues.push({
689
+ source: 'custom',
690
+ id: ni.type,
691
+ severity: ni.severity,
692
+ theme: load.theme,
693
+ viewport: load.viewport,
694
+ help: ni.message,
695
+ fix: FIX_HINTS[ni.type] || 'Дать элементу доступное имя.',
696
+ nodes: ni.nodes,
697
+ nodeCount: (ni.nodes || []).length || 1,
698
+ });
699
+ }
700
+ if (load.fonts && load.fonts.status && load.fonts.status !== 'loaded') {
701
+ issues.push({
702
+ source: 'custom',
703
+ id: 'font-not-loaded',
704
+ severity: 'critical',
705
+ theme: load.theme,
706
+ viewport: load.viewport,
707
+ help: `document.fonts.status = "${load.fonts.status}" (ожидалось "loaded")`,
708
+ fix: FIX_HINTS['font-not-loaded'],
709
+ nodes: (load.fonts.faces || []).filter((f) => f.status !== 'loaded').slice(0, 10).map((f) => ({
710
+ target: `${f.family} ${f.weight} ${f.style}`,
711
+ html: `status: ${f.status}`,
712
+ })),
713
+ nodeCount: 1,
714
+ });
715
+ }
716
+ for (const face of (load.fonts?.faces || []).filter((f) => f.status === 'error')) {
717
+ issues.push({
718
+ source: 'custom',
719
+ id: 'font-face-error',
720
+ severity: 'serious',
721
+ theme: load.theme,
722
+ viewport: load.viewport,
723
+ help: `Шрифт не загрузился: ${face.family} ${face.weight} ${face.style}`,
724
+ fix: FIX_HINTS['font-face-error'],
725
+ nodes: [],
726
+ nodeCount: 1,
727
+ });
728
+ }
729
+ }
730
+ for (const e of result.console.pageErrors) {
731
+ issues.push({
732
+ source: 'custom',
733
+ id: 'page-error',
734
+ severity: 'critical',
735
+ theme: e.tag?.split('/')[0],
736
+ viewport: e.tag?.split('/')[1],
737
+ help: 'Необработанное исключение JS',
738
+ fix: FIX_HINTS['page-error'],
739
+ nodes: [{ target: 'console', html: e.text }],
740
+ nodeCount: 1,
741
+ });
742
+ }
743
+ for (const e of result.console.consoleErrors) {
744
+ issues.push({
745
+ source: 'custom',
746
+ id: 'console-error',
747
+ severity: 'serious',
748
+ theme: e.tag?.split('/')[0],
749
+ viewport: e.tag?.split('/')[1],
750
+ help: 'Ошибка в консоли браузера',
751
+ fix: FIX_HINTS['console-error'],
752
+ nodes: [{ target: 'console', html: e.text }],
753
+ nodeCount: 1,
754
+ });
755
+ }
756
+ for (const e of result.console.failedRequests) {
757
+ issues.push({
758
+ source: 'custom',
759
+ id: 'request-failed',
760
+ severity: ['script', 'stylesheet'].includes(e.resourceType) ? 'serious' : 'moderate',
761
+ theme: e.tag?.split('/')[0],
762
+ viewport: e.tag?.split('/')[1],
763
+ help: `Сбой загрузки ресурса: ${e.url}`,
764
+ fix: FIX_HINTS['request-failed'],
765
+ nodes: [{ target: e.url, html: e.error || `HTTP ${e.status}` }],
766
+ nodeCount: 1,
767
+ });
768
+ }
769
+
770
+ // дедупликация кросс-комбинационных повторов (одни и те же DOM-проблемы в 2 темах/вьюпортах)
771
+ const deduped = dedupeIssues(issues);
772
+ deduped.sort(compareIssues);
773
+ result.issues = deduped;
774
+
775
+ const counts = countSeverities(deduped);
776
+ result.summary = {
777
+ verdict: counts.critical + counts.serious > 0 ? 'FAIL' : 'PASS',
778
+ exitCode: counts.critical + counts.serious > 0 ? 2 : 0,
779
+ counts,
780
+ axeViolationIds: unique(deduped.filter((i) => i.source === 'axe').map((i) => i.id)).length,
781
+ axeNodes: deduped.filter((i) => i.source === 'axe').reduce((s, i) => s + (i.nodeCount || 0), 0),
782
+ customIssues: deduped.filter((i) => i.source === 'custom').length,
783
+ consoleErrors: result.console.consoleErrors.length,
784
+ pageErrors: result.console.pageErrors.length,
785
+ failedRequests: result.console.failedRequests.length,
786
+ fontsStatus: result.fonts.status,
787
+ fontsTotal: result.fonts.total,
788
+ fontsErrors: result.fonts.faces.filter((f) => f.status === 'error').length,
789
+ };
790
+ result.exitCode = result.fatalError ? 1 : result.summary.exitCode;
791
+
792
+ const json = JSON.stringify(result, null, 2);
793
+ fs.writeFileSync(files.json, json, 'utf8');
794
+ const md = renderMarkdown(result);
795
+ fs.writeFileSync(files.md, md, 'utf8');
796
+
797
+ log('');
798
+ log(`[audit] вердикт: ${result.fatalError ? 'ОШИБКА ИНСТРУМЕНТА' : result.summary.verdict}`);
799
+ log(
800
+ `[audit] critical=${counts.critical} serious=${counts.serious} moderate=${counts.moderate} minor=${counts.minor}; ` +
801
+ `axe-нарушений: ${result.summary.axeViolationIds} (${result.summary.axeNodes} узлов); ` +
802
+ `console.error=${result.summary.consoleErrors}, pageerror=${result.summary.pageErrors}, failedRequests=${result.summary.failedRequests}`,
803
+ );
804
+ log(`[audit] отчёт: ${files.json}`);
805
+ log(`[audit] сводка: ${files.md}`);
806
+ const top = deduped.slice(0, 10);
807
+ if (top.length) {
808
+ log('[audit] что чинить в первую очередь:');
809
+ top.forEach((i, n) => log(` ${n + 1}. [${i.severity}] ${i.id} — ${shorten(i.help, 120)}`));
810
+ }
811
+
812
+ if (args.json) process.stdout.write(json + '\n');
813
+ process.exitCode = result.exitCode;
814
+ }
815
+
816
+ function readJsonSafe(file) {
817
+ try {
818
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
819
+ } catch {
820
+ return null;
821
+ }
822
+ }
823
+
824
+ function defaultName(resolved) {
825
+ if (resolved.abs) return path.basename(resolved.abs, path.extname(resolved.abs));
826
+ try {
827
+ const u = new URL(resolved.url);
828
+ const last = u.pathname.split('/').filter(Boolean).pop() || 'page';
829
+ return path.basename(last, path.extname(last));
830
+ } catch {
831
+ return 'page';
832
+ }
833
+ }
834
+
835
+ function normalizeSeverity(impact) {
836
+ return SEVERITY.includes(impact) ? impact : 'moderate';
837
+ }
838
+
839
+ function shorten(text, n) {
840
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
841
+ return s.length > n ? s.slice(0, n) + '…' : s;
842
+ }
843
+
844
+ function trimAxeResult(raw) {
845
+ if (!raw || raw.error) {
846
+ return { error: raw?.error || 'axe.run вернул пустой результат', violations: [], incomplete: [] };
847
+ }
848
+ return {
849
+ engine: raw.testEngine?.name && raw.testEngine?.version ? `${raw.testEngine.name} ${raw.testEngine.version}` : null,
850
+ violations: (raw.violations || []).map((v) => ({
851
+ id: v.id,
852
+ impact: v.impact,
853
+ tags: v.tags || [],
854
+ description: v.description,
855
+ help: v.help,
856
+ helpUrl: v.helpUrl,
857
+ nodeCount: v.nodes?.length || 0,
858
+ nodes: (v.nodes || []).slice(0, 25).map((n) => ({
859
+ target: Array.isArray(n.target) ? n.target.join(' >>> ') : String(n.target || ''),
860
+ html: shorten(n.html, 300),
861
+ failureSummary: shorten(n.failureSummary, 400),
862
+ })),
863
+ })),
864
+ incompleteCount: (raw.incomplete || []).reduce((s, v) => s + (v.nodes?.length || 0), 0),
865
+ incompleteIds: unique((raw.incomplete || []).map((v) => v.id)),
866
+ };
867
+ }
868
+
869
+ function trimCustom(raw) {
870
+ const trimNode = (n) => ({ target: n.target, html: shorten(n.html, 200) });
871
+ return {
872
+ viewportWidth: raw.viewportWidth,
873
+ clientWidth: raw.clientWidth,
874
+ scrollWidth: raw.scrollWidth,
875
+ overflow: {
876
+ overflowing: raw.overflow?.overflowing || false,
877
+ clientWidth: raw.clientWidth ?? null,
878
+ scrollWidth: raw.scrollWidth ?? null,
879
+ offenders: (raw.overflow?.offenders || []).slice(0, 15),
880
+ },
881
+ tapTargets: (raw.tapTargets || []).slice(0, 60),
882
+ tapTargetsAdvisory: (raw.tapTargetsAdvisory || []).slice(0, 20),
883
+ headings: {
884
+ total: (raw.headings || []).length,
885
+ h1: (raw.headings || []).filter((h) => h.level === 1).length,
886
+ issues: (raw.headingIssues || []).map((h) => ({ ...h, nodes: (h.nodes || []).map(trimNode) })),
887
+ },
888
+ images: {
889
+ total: raw.images?.total || 0,
890
+ missingAlt: (raw.images?.missingAlt || []).slice(0, 25).map((i) => ({ selector: i.selector, src: i.src })),
891
+ broken: (raw.images?.broken || []).slice(0, 25).map((i) => ({ selector: i.selector, src: i.src })),
892
+ pending: (raw.images?.pending || []).length,
893
+ },
894
+ nameIssues: (raw.nameIssues || []).slice(0, 25).map((n) => ({ ...n, nodes: (n.nodes || []).map(trimNode) })),
895
+ fonts: {
896
+ status: raw.fonts?.status ?? null,
897
+ size: raw.fonts?.size ?? 0,
898
+ faces: (raw.fonts?.faces || []).slice(0, 40),
899
+ },
900
+ };
901
+ }
902
+
903
+ function mergeFonts(allFonts) {
904
+ const faces = [];
905
+ const seen = new Set();
906
+ let status = 'unknown';
907
+ let total = 0;
908
+ for (const entry of allFonts) {
909
+ if (entry.status === 'error' || status === 'error') status = 'error';
910
+ else if (entry.status === 'loading') status = 'loading';
911
+ else if (entry.status && status !== 'error' && status !== 'loading') status = entry.status;
912
+ total = Math.max(total, entry.size || 0);
913
+ for (const f of entry.faces || []) {
914
+ const key = `${f.family}|${f.weight}|${f.style}|${f.status}`;
915
+ if (seen.has(key)) continue;
916
+ seen.add(key);
917
+ faces.push(f);
918
+ }
919
+ }
920
+ return { status, total, faces, loads: allFonts.length };
921
+ }
922
+
923
+ /** Одинаковые DOM-находки из разных тем/вьюпортов схлопываем, сохраняя где встречались. */
924
+ function dedupeIssues(issues) {
925
+ const byKey = new Map();
926
+ for (const issue of issues) {
927
+ const firstNode = issue.nodes?.[0] || {};
928
+ // Контраст зависит от темы — не склеиваем между темами (внутри темы склеиваем
929
+ // desktop/mobile). Остальные axe-правила склеиваем по id+узлу, чтобы одна и та же
930
+ // находка не дублировалась на light/dark (html-атрибуты у тем разные).
931
+ const themeScoped = issue.source === 'axe' && issue.id === 'color-contrast';
932
+ const keyParts = [
933
+ issue.source,
934
+ issue.id,
935
+ themeScoped ? issue.theme || '' : '',
936
+ firstNode.target || '',
937
+ issue.help || '',
938
+ ];
939
+ const key = keyParts.join('|');
940
+ const prev = byKey.get(key);
941
+ const where = `${issue.theme || '?'}/${issue.viewport || '?'}`;
942
+ if (prev) {
943
+ prev.seenIn = unique([...(prev.seenIn || []), where]);
944
+ prev.nodeCount = Math.max(prev.nodeCount || 0, issue.nodeCount || 0);
945
+ continue;
946
+ }
947
+ byKey.set(key, { ...issue, seenIn: [where] });
948
+ }
949
+ // склеиваем «сиротские» повторы document-проверок между темами (heading/image/name)
950
+ const docScoped = new Set([
951
+ 'heading-none',
952
+ 'heading-multiple-h1',
953
+ 'heading-skipped',
954
+ 'image-alt-missing',
955
+ 'image-broken',
956
+ 'link-empty-name',
957
+ 'button-empty-name',
958
+ 'font-not-loaded',
959
+ 'font-face-error',
960
+ ]);
961
+ const out = [];
962
+ const docKeys = new Map();
963
+ for (const issue of byKey.values()) {
964
+ if (docScoped.has(issue.id)) {
965
+ const nodeKey = (issue.nodes || []).map((n) => n.target).join(',');
966
+ const k = `${issue.id}|${nodeKey}|${issue.help}`;
967
+ const prev = docKeys.get(k);
968
+ if (prev) {
969
+ prev.seenIn = unique([...(prev.seenIn || []), ...(issue.seenIn || [])]);
970
+ continue;
971
+ }
972
+ docKeys.set(k, issue);
973
+ const nodes = issue.nodes || [];
974
+ issue.nodes = nodes;
975
+ }
976
+ out.push(issue);
977
+ }
978
+ return out;
979
+ }
980
+
981
+ function compareIssues(a, b) {
982
+ const bySeverity = (SEVERITY_RANK[b.severity] || 0) - (SEVERITY_RANK[a.severity] || 0);
983
+ if (bySeverity) return bySeverity;
984
+ const bySource = (a.source === 'custom' ? 0 : 1) - (b.source === 'custom' ? 0 : 1);
985
+ if (bySource) return bySource;
986
+ return String(a.id).localeCompare(String(b.id));
987
+ }
988
+
989
+ function countSeverities(issues) {
990
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
991
+ for (const i of issues) counts[normalizeSeverity(i.severity)]++;
992
+ return counts;
993
+ }
994
+
995
+ function unique(arr) {
996
+ return [...new Set(arr.filter((v) => v !== undefined && v !== null && v !== ''))];
997
+ }
998
+
999
+ // ---------------------------------------------------------------------------
1000
+ // Markdown-отчёт
1001
+ // ---------------------------------------------------------------------------
1002
+
1003
+ function renderMarkdown(result) {
1004
+ const lines = [];
1005
+ const s = result.summary;
1006
+ const push = (...l) => lines.push(...l);
1007
+
1008
+ push(`# Визуальный аудит: ${result.name}`, '');
1009
+ push(`- **Источник:** \`${result.source}\``);
1010
+ if (result.url && result.url !== result.source) push(`- **URL:** ${result.url}`);
1011
+ push(`- **Дата:** ${result.finishedAt} (${(result.durationMs / 1000).toFixed(1)} c)`);
1012
+ push(`- **Браузер:** ${result.browser?.browser || '—'}${result.browser?.launched ? ' (запущен fallback)' : ' (CDP)'}`);
1013
+ push(`- **axe-core:** ${result.options.axeVersion}; темы: ${result.options.themes.join(', ')}; вьюпорты: ${result.options.viewports.map((v) => `${v.id} ${v.width}×${v.height}`).join(', ')}`);
1014
+ if (result.options.preset) push(`- **Пресет:** \`${result.options.preset}\``);
1015
+ push('');
1016
+
1017
+ if (result.fatalError) {
1018
+ push('## Вердикт: ОШИБКА ИНСТРУМЕНТА', '', '```', result.fatalError, '```', '');
1019
+ return lines.join('\n');
1020
+ }
1021
+
1022
+ push(
1023
+ `## Вердикт: ${s.verdict === 'PASS' ? 'PASS ✅' : 'FAIL ❌'}`,
1024
+ '',
1025
+ `- critical: **${s.counts.critical}**, serious: **${s.counts.serious}**, moderate: ${s.counts.moderate}, minor: ${s.counts.minor}`,
1026
+ `- axe-нарушений: ${s.axeViolationIds} (${s.axeNodes} узлов); кастомных замечаний: ${s.customIssues}`,
1027
+ `- ошибок консоли: ${s.consoleErrors}; pageerror: ${s.pageErrors}; сбоев загрузки: ${s.failedRequests}`,
1028
+ `- шрифты: document.fonts.status = **${s.fontsStatus}** (faces: ${s.fontsTotal}, ошибок: ${s.fontsErrors})`,
1029
+ `- код выхода: **${s.exitCode}** (${s.exitCode === 2 ? 'есть critical/serious' : 'критичных проблем нет'})`,
1030
+ '',
1031
+ );
1032
+
1033
+ const top = result.issues.slice(0, 10);
1034
+ push('## Что чинить в первую очередь', '');
1035
+ if (!top.length) {
1036
+ push('Критичных и серьёзных проблем не найдено. Проверьте moderate/minor ниже.', '');
1037
+ } else {
1038
+ top.forEach((issue, idx) => {
1039
+ const where = issue.seenIn?.length ? ` — ${issue.seenIn.join(', ')}` : '';
1040
+ push(
1041
+ `${idx + 1}. **[${issue.severity}] ${issue.id}** (${issue.source}) — ${shorten(issue.help, 220)}${where}`,
1042
+ ` - *Как исправить:* ${shorten(issue.fix || '', 260)}`,
1043
+ );
1044
+ const nodes = (issue.nodes || []).slice(0, 3);
1045
+ if (nodes.length) {
1046
+ for (const n of nodes) push(` - \`${shorten(n.target, 140)}\`${n.html ? ` — ${shorten(n.html, 160)}` : ''}`);
1047
+ }
1048
+ });
1049
+ push('');
1050
+ }
1051
+
1052
+ push('## Сводка по severity и источникам', '', '| severity | axe | custom | всего |', '|---|---:|---:|---:|');
1053
+ for (const sev of SEVERITY) {
1054
+ const axe = result.issues.filter((i) => i.source === 'axe' && i.severity === sev).length;
1055
+ const custom = result.issues.filter((i) => i.source === 'custom' && i.severity === sev).length;
1056
+ if (!axe && !custom) continue;
1057
+ push(`| ${sev} | ${axe} | ${custom} | ${axe + custom} |`);
1058
+ }
1059
+ push('');
1060
+
1061
+ const axeViolations = result.issues.filter((i) => i.source === 'axe');
1062
+ push('## Axe: нарушения', '');
1063
+ if (!axeViolations.length) {
1064
+ push('Нарушений axe-core нет.', '');
1065
+ } else {
1066
+ for (const v of axeViolations) {
1067
+ const where = v.seenIn?.length ? ` — ${v.seenIn.join(', ')}` : '';
1068
+ push(`### ${v.id} — ${v.severity}${where}`, '');
1069
+ push(`${v.help || ''}`);
1070
+ if (v.helpUrl) push(`Справка: <${v.helpUrl}>`);
1071
+ push(`Узлов: ${v.nodeCount}; *как исправить:* ${v.fix || '—'}`, '');
1072
+ for (const n of (v.nodes || []).slice(0, 10)) {
1073
+ push(`- \`${shorten(n.target, 180)}\``);
1074
+ if (n.failureSummary) push(` - ${shorten(n.failureSummary, 300)}`);
1075
+ }
1076
+ if ((v.nodes || []).length > 10) push(`- …ещё ${v.nodes.length - 10} узлов в audit.json`);
1077
+ push('');
1078
+ }
1079
+ }
1080
+
1081
+ push('## Кастомные проверки', '');
1082
+ const firstLoad = result.custom.find((c) => !c.error) || {};
1083
+ push('### Горизонтальный overflow (390px)', '');
1084
+ const overflowLoads = result.custom.filter((c) => c.overflow?.overflowing);
1085
+ if (overflowLoads.length === 0) {
1086
+ push('Горизонтального скролла нет на всех проверенных ширинах. ✅', '');
1087
+ } else {
1088
+ for (const load of overflowLoads) {
1089
+ push(`- **${load.theme}/${load.viewport}**: scrollWidth ${load.overflow.scrollWidth} > clientWidth ${load.overflow.clientWidth}px`);
1090
+ for (const o of load.overflow.offenders.slice(0, 10)) {
1091
+ push(` - \`${shorten(o.selector, 160)}\` — ${o.width}px (${o.left}..${o.right}) ${shorten(o.text, 100)}`);
1092
+ }
1093
+ }
1094
+ push('');
1095
+ }
1096
+
1097
+ push('### Tap-targets < 40×40px (WCAG 2.5.8)', '');
1098
+ const tapIssues = result.issues.filter((i) => i.id === 'tap-target-tiny' || i.id === 'tap-target-small');
1099
+ const tapAdvisory = result.custom.reduce((s, c) => s + (c.tapTargetsAdvisory?.length || 0), 0);
1100
+ if (!tapIssues.length) {
1101
+ push('Все интерактивные цели не меньше 40×40px (кроме инлайновых ссылок). ✅', '');
1102
+ } else {
1103
+ for (const issue of tapIssues) {
1104
+ push(`- **[${issue.severity}]** ${issue.help}${issue.seenIn?.length ? ` — ${issue.seenIn.join(', ')}` : ''}`);
1105
+ for (const n of (issue.nodes || []).slice(0, 10)) {
1106
+ push(` - \`${shorten(n.target, 140)}\` — ${shorten(n.html, 80)}`);
1107
+ }
1108
+ if ((issue.nodes || []).length > 10) push(` - …ещё ${issue.nodes.length - 10} целей в audit.json`);
1109
+ }
1110
+ }
1111
+ if (tapAdvisory) {
1112
+ push(`_Advisory (не влияет на вердикт): не учитывались инлайновые ссылки в тексте/навигации — ${tapAdvisory}._`);
1113
+ }
1114
+ push('');
1115
+
1116
+ push('### Заголовки', '');
1117
+ const headingIssues = result.issues.filter((i) => String(i.id).startsWith('heading-'));
1118
+ if (!headingIssues.length) {
1119
+ push(`Порядок заголовков корректный (h1: ${firstLoad.headings?.h1 ?? '?'}, всего ${firstLoad.headings?.total ?? '?'}). ✅`, '');
1120
+ } else {
1121
+ for (const h of headingIssues) {
1122
+ push(`- [${h.severity}] ${h.help} — ${(h.nodes || []).map((n) => `\`${shorten(n.target, 80)}\` ${shorten(n.html, 80)}`).join('; ')}`);
1123
+ }
1124
+ push('');
1125
+ }
1126
+
1127
+ push('### Изображения', '');
1128
+ const imgIssues = result.issues.filter((i) => ['image-alt-missing', 'image-broken'].includes(i.id));
1129
+ if (!imgIssues.length) {
1130
+ push(`У всех изображений есть alt, битых нет (всего изображений: ${firstLoad.images?.total ?? 0}). ✅`, '');
1131
+ } else {
1132
+ for (const i of imgIssues) {
1133
+ push(`- [${i.severity}] ${i.id}: ${(i.nodes || []).map((n) => `\`${shorten(n.target, 120)}\` ${shorten(n.html, 120)}`).join('; ')}`);
1134
+ }
1135
+ push('');
1136
+ }
1137
+
1138
+ push('### Ссылки и кнопки без доступного имени', '');
1139
+ const nameIssues = result.issues.filter((i) => ['link-empty-name', 'button-empty-name'].includes(i.id));
1140
+ if (!nameIssues.length) {
1141
+ push('Пустых ссылок/кнопок без имени нет. ✅', '');
1142
+ } else {
1143
+ for (const i of nameIssues) {
1144
+ push(`- [${i.severity}] ${i.id}: ${(i.nodes || []).map((n) => `\`${shorten(n.target, 120)}\``).join('; ')}`);
1145
+ }
1146
+ push('');
1147
+ }
1148
+
1149
+ push('### Шрифты', '');
1150
+ push(`document.fonts.status = **${s.fontsStatus}**; загружено faces: ${s.fontsTotal}; ошибок: ${s.fontsErrors}.`);
1151
+ const fontIssues = result.issues.filter((i) => ['font-not-loaded', 'font-face-error'].includes(i.id));
1152
+ if (!fontIssues.length) push('Все веб-шрифты загрузились. ✅');
1153
+ for (const i of fontIssues) push(`- [${i.severity}] ${i.help}`);
1154
+ push('');
1155
+
1156
+ push('## Консоль и сеть', '');
1157
+ if (!result.console.consoleErrors.length && !result.console.pageErrors.length && !result.console.failedRequests.length) {
1158
+ push('Ошибок консоли, pageerror и сбоев загрузки нет. ✅', '');
1159
+ } else {
1160
+ if (result.console.pageErrors.length) {
1161
+ push('**pageerror:**');
1162
+ for (const e of result.console.pageErrors.slice(0, 10)) push(`- [${e.tag}] ${shorten(e.text, 300)}`);
1163
+ push('');
1164
+ }
1165
+ if (result.console.consoleErrors.length) {
1166
+ push('**console.error:**');
1167
+ for (const e of result.console.consoleErrors.slice(0, 15)) push(`- [${e.tag}] ${shorten(e.text, 300)}`);
1168
+ push('');
1169
+ }
1170
+ if (result.console.failedRequests.length) {
1171
+ push('**Сбои загрузки:**');
1172
+ for (const e of result.console.failedRequests.slice(0, 20)) push(`- [${e.tag}] ${e.resourceType || ''} ${shorten(e.url, 240)} — ${e.error || `HTTP ${e.status}`}`);
1173
+ push('');
1174
+ }
1175
+ }
1176
+
1177
+ push('## Детали прогонов', '', '| тема | viewport | axe-нарушений | overflow | tap-targets | шрифты |', '|---|---|---:|---|---|---|');
1178
+ for (const load of result.custom) {
1179
+ const axeLoad = result.axe.find((a) => a.theme === load.theme && a.viewport === load.viewport);
1180
+ push(
1181
+ `| ${load.theme} | ${load.viewport} | ${axeLoad?.violations?.length ?? 0} | ${load.overflow?.overflowing ? '⚠️ yes' : 'ok'} | ${load.tapTargets?.length ?? 0} | ${load.fonts?.status ?? '—'} |`,
1182
+ );
1183
+ }
1184
+ push('');
1185
+ push('---', '');
1186
+ push('_Отчёт сгенерирован tools/design-qa/audit.mjs. Подробности (все узлы, HTML, failureSummary) — в audit.json._');
1187
+
1188
+ return lines.join('\n');
1189
+ }
1190
+
1191
+ main().catch((err) => {
1192
+ if (err?.isUsageError) {
1193
+ console.error(`[audit] ${err.message}`);
1194
+ console.error('[audit] подсказка: запустите node tools/design-qa/audit.mjs --help');
1195
+ process.exitCode = 1;
1196
+ return;
1197
+ }
1198
+ console.error('[audit] НЕОЖИДАННАЯ ОШИБКА:', err);
1199
+ process.exitCode = 1;
1200
+ });