@palate/cli 0.0.0-stage → 0.0.2

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 (1222) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +153 -2
  3. package/bin/palate.mjs +894 -0
  4. package/kb/anatomy/data-table.md +24 -0
  5. package/kb/anatomy/empty-state.md +16 -0
  6. package/kb/anatomy/filter-toolbar.md +19 -0
  7. package/kb/anatomy/meter.md +16 -0
  8. package/kb/anatomy/product-window.md +18 -0
  9. package/kb/anatomy/settings-page.md +18 -0
  10. package/kb/anatomy/status-indicator.md +17 -0
  11. package/kb/anatomy/toast-undo.md +16 -0
  12. package/kb/bench/briefs.json +618 -0
  13. package/kb/craft/SOURCES.md +48 -0
  14. package/kb/craft/creativity.md +55 -0
  15. package/kb/craft/details.md +75 -0
  16. package/kb/craft/landing-demos.md +46 -0
  17. package/kb/craft/mistakes.md +73 -0
  18. package/kb/craft/motion.md +53 -0
  19. package/kb/craft/principles.md +68 -0
  20. package/kb/index/discovery.json +1 -0
  21. package/kb/inspiration/index.json +79 -0
  22. package/kb/moves/animated-comparison-table.md +58 -0
  23. package/kb/moves/before-after-slider.md +58 -0
  24. package/kb/moves/bento-live-tiles.md +57 -0
  25. package/kb/moves/calm-motion-system.md +63 -0
  26. package/kb/moves/canvas-grain.md +58 -0
  27. package/kb/moves/changelog-as-magazine.md +56 -0
  28. package/kb/moves/code-drawn-diagram.md +56 -0
  29. package/kb/moves/colophon.md +54 -0
  30. package/kb/moves/command-palette-site-nav.md +58 -0
  31. package/kb/moves/confetti-free-celebration.md +58 -0
  32. package/kb/moves/console-easter-egg.md +57 -0
  33. package/kb/moves/copy-confirmation.md +57 -0
  34. package/kb/moves/cursor-reactive-light.md +57 -0
  35. package/kb/moves/dark-band-contrast-section.md +55 -0
  36. package/kb/moves/designed-dark-mode.md +58 -0
  37. package/kb/moves/duotone-photography.md +55 -0
  38. package/kb/moves/editorial-drop-cap.md +58 -0
  39. package/kb/moves/editorial-grid-pull-quotes.md +55 -0
  40. package/kb/moves/engraving-colour-lens.md +64 -0
  41. package/kb/moves/explorable-control.md +57 -0
  42. package/kb/moves/feature-films-on-painted-stages.md +58 -0
  43. package/kb/moves/first-win-onboarding.md +59 -0
  44. package/kb/moves/footer-live-status.md +56 -0
  45. package/kb/moves/footer-rewards-the-end.md +58 -0
  46. package/kb/moves/generative-brand-pattern.md +55 -0
  47. package/kb/moves/gentle-error-nudge.md +59 -0
  48. package/kb/moves/halftone-dot-imagery.md +55 -0
  49. package/kb/moves/hand-drawn-annotations.md +55 -0
  50. package/kb/moves/hero-paints-itself-in.md +65 -0
  51. package/kb/moves/honest-progress-narration.md +58 -0
  52. package/kb/moves/honest-testimonial-wall.md +57 -0
  53. package/kb/moves/inline-sparklines-in-copy.md +57 -0
  54. package/kb/moves/inspect-our-taste.md +58 -0
  55. package/kb/moves/interactive-pricing-calculator.md +59 -0
  56. package/kb/moves/isometric-illustration-system.md +56 -0
  57. package/kb/moves/kinetic-variable-type.md +57 -0
  58. package/kb/moves/konami-secret.md +59 -0
  59. package/kb/moves/layered-paper-depth.md +56 -0
  60. package/kb/moves/lightweight-3d-object-hero.md +59 -0
  61. package/kb/moves/line-engraving-from-image.md +63 -0
  62. package/kb/moves/link-preview-peek.md +57 -0
  63. package/kb/moves/live-data-ticker.md +61 -0
  64. package/kb/moves/made-with-proof.md +51 -0
  65. package/kb/moves/magnetic-buttons.md +55 -0
  66. package/kb/moves/marquee-done-right.md +58 -0
  67. package/kb/moves/moves.yaml +450 -0
  68. package/kb/moves/museum-painting-backdrop.md +59 -0
  69. package/kb/moves/one-colour-brand-world.md +56 -0
  70. package/kb/moves/playful-404.md +58 -0
  71. package/kb/moves/pointer-highlight-word.md +56 -0
  72. package/kb/moves/product-in-context.md +56 -0
  73. package/kb/moves/real-world-data.md +58 -0
  74. package/kb/moves/referral-reward-reveal.md +59 -0
  75. package/kb/moves/riso-print-texture.md +57 -0
  76. package/kb/moves/screenshot-detail-crops.md +56 -0
  77. package/kb/moves/scroll-progress-chapters.md +57 -0
  78. package/kb/moves/scroll-scrubbed-product-film.md +62 -0
  79. package/kb/moves/self-playing-product-demo.md +61 -0
  80. package/kb/moves/shape-continuity-morph.md +58 -0
  81. package/kb/moves/sliding-nav-indicator.md +58 -0
  82. package/kb/moves/sticky-scroll-story.md +56 -0
  83. package/kb/moves/story-led-long-scroll.md +56 -0
  84. package/kb/moves/tactile-button.md +63 -0
  85. package/kb/moves/technical-drawing-lines.md +57 -0
  86. package/kb/moves/time-of-day-theming.md +56 -0
  87. package/kb/moves/tiny-game-easter-egg.md +60 -0
  88. package/kb/moves/type-decode-reveal.md +57 -0
  89. package/kb/moves/typographic-numeral-hero.md +58 -0
  90. package/kb/moves/view-transitions-between-pages.md +57 -0
  91. package/kb/moves/waitlist-position-live.md +60 -0
  92. package/kb/patterns/app-team-members.md +27 -0
  93. package/kb/patterns/chat-agent-approvals.md +31 -0
  94. package/kb/patterns/commerce-product-page.md +34 -0
  95. package/kb/patterns/dashboard-entity-table.md +37 -0
  96. package/kb/patterns/dashboard-home-today.md +24 -0
  97. package/kb/patterns/docs-api-reference.md +31 -0
  98. package/kb/patterns/marketing-product-led-landing.md +30 -0
  99. package/kb/patterns/mobile-daily-checklist.md +26 -0
  100. package/kb/references/index.json +1 -0
  101. package/kb/references/thumbs/004c5e9072.jpg +0 -0
  102. package/kb/references/thumbs/008915eb21.jpg +0 -0
  103. package/kb/references/thumbs/0098cf66c2.jpg +0 -0
  104. package/kb/references/thumbs/012e61b31f.jpg +0 -0
  105. package/kb/references/thumbs/014d4e46fc.jpg +0 -0
  106. package/kb/references/thumbs/01e54f394a.jpg +0 -0
  107. package/kb/references/thumbs/01e99a579c.jpg +0 -0
  108. package/kb/references/thumbs/01fd014575.jpg +0 -0
  109. package/kb/references/thumbs/022778ca72.jpg +0 -0
  110. package/kb/references/thumbs/02717bf1e1.jpg +0 -0
  111. package/kb/references/thumbs/02a5cfdbe9.jpg +0 -0
  112. package/kb/references/thumbs/02c9823f8d.jpg +0 -0
  113. package/kb/references/thumbs/02f3974613.jpg +0 -0
  114. package/kb/references/thumbs/032427f1ce.jpg +0 -0
  115. package/kb/references/thumbs/03ba677d07.jpg +0 -0
  116. package/kb/references/thumbs/04f2476cfe.jpg +0 -0
  117. package/kb/references/thumbs/0563e7c52e.jpg +0 -0
  118. package/kb/references/thumbs/05fa6b8eef.jpg +0 -0
  119. package/kb/references/thumbs/065bce1517.jpg +0 -0
  120. package/kb/references/thumbs/06c017f113.jpg +0 -0
  121. package/kb/references/thumbs/0770e2d8d3.jpg +0 -0
  122. package/kb/references/thumbs/078a26c4cb.jpg +0 -0
  123. package/kb/references/thumbs/08679b6e84.jpg +0 -0
  124. package/kb/references/thumbs/088c45a34c.jpg +0 -0
  125. package/kb/references/thumbs/089e9fd4e4.jpg +0 -0
  126. package/kb/references/thumbs/08f773231e.jpg +0 -0
  127. package/kb/references/thumbs/093ca3e2e3.jpg +0 -0
  128. package/kb/references/thumbs/0963320f74.jpg +0 -0
  129. package/kb/references/thumbs/09fc1ded85.jpg +0 -0
  130. package/kb/references/thumbs/0a368f3963.jpg +0 -0
  131. package/kb/references/thumbs/0a9e0e5201.jpg +0 -0
  132. package/kb/references/thumbs/0aace67ee7.jpg +0 -0
  133. package/kb/references/thumbs/0b61ecf793.jpg +0 -0
  134. package/kb/references/thumbs/0bde18ae09.jpg +0 -0
  135. package/kb/references/thumbs/0bf7ee8143.jpg +0 -0
  136. package/kb/references/thumbs/0ca20a13e0.jpg +0 -0
  137. package/kb/references/thumbs/0cdc0198c4.jpg +0 -0
  138. package/kb/references/thumbs/0cf9790b28.jpg +0 -0
  139. package/kb/references/thumbs/0d0202904b.jpg +0 -0
  140. package/kb/references/thumbs/0d053aba18.jpg +0 -0
  141. package/kb/references/thumbs/0d0c2c13d2.jpg +0 -0
  142. package/kb/references/thumbs/0d30be99b0.jpg +0 -0
  143. package/kb/references/thumbs/0d503da1b7.jpg +0 -0
  144. package/kb/references/thumbs/0ddad7b740.jpg +0 -0
  145. package/kb/references/thumbs/0e0a148101.jpg +0 -0
  146. package/kb/references/thumbs/0e401b6b75.jpg +0 -0
  147. package/kb/references/thumbs/0e8f32ba0f.jpg +0 -0
  148. package/kb/references/thumbs/0ef1966a7d.jpg +0 -0
  149. package/kb/references/thumbs/0efd416d11.jpg +0 -0
  150. package/kb/references/thumbs/0f3af263ae.jpg +0 -0
  151. package/kb/references/thumbs/0f9644c3a5.jpg +0 -0
  152. package/kb/references/thumbs/1020552737.jpg +0 -0
  153. package/kb/references/thumbs/1037626587.jpg +0 -0
  154. package/kb/references/thumbs/10512b7e94.jpg +0 -0
  155. package/kb/references/thumbs/106dc4a7b5.jpg +0 -0
  156. package/kb/references/thumbs/108943f6f7.jpg +0 -0
  157. package/kb/references/thumbs/10a5551d51.jpg +0 -0
  158. package/kb/references/thumbs/10dd01076c.jpg +0 -0
  159. package/kb/references/thumbs/11b06e39da.jpg +0 -0
  160. package/kb/references/thumbs/11baa1ec83.jpg +0 -0
  161. package/kb/references/thumbs/11bbe12167.jpg +0 -0
  162. package/kb/references/thumbs/11ca37feb8.jpg +0 -0
  163. package/kb/references/thumbs/11f14d3f12.jpg +0 -0
  164. package/kb/references/thumbs/124530ceb5.jpg +0 -0
  165. package/kb/references/thumbs/1276402336.jpg +0 -0
  166. package/kb/references/thumbs/1280e5c9d6.jpg +0 -0
  167. package/kb/references/thumbs/12cac0d172.jpg +0 -0
  168. package/kb/references/thumbs/138be6623a.jpg +0 -0
  169. package/kb/references/thumbs/13a0c544d1.jpg +0 -0
  170. package/kb/references/thumbs/13a32cb94c.jpg +0 -0
  171. package/kb/references/thumbs/144035d641.jpg +0 -0
  172. package/kb/references/thumbs/1457e41924.jpg +0 -0
  173. package/kb/references/thumbs/14972e3a86.jpg +0 -0
  174. package/kb/references/thumbs/15a47f2e4c.jpg +0 -0
  175. package/kb/references/thumbs/15aa15d22e.jpg +0 -0
  176. package/kb/references/thumbs/160dcd24b7.jpg +0 -0
  177. package/kb/references/thumbs/1610978529.jpg +0 -0
  178. package/kb/references/thumbs/16db30ba37.jpg +0 -0
  179. package/kb/references/thumbs/16f19ae4f3.jpg +0 -0
  180. package/kb/references/thumbs/16f758b03f.jpg +0 -0
  181. package/kb/references/thumbs/1748e5b751.jpg +0 -0
  182. package/kb/references/thumbs/175d0b0e69.jpg +0 -0
  183. package/kb/references/thumbs/178f571a36.jpg +0 -0
  184. package/kb/references/thumbs/17c43dc025.jpg +0 -0
  185. package/kb/references/thumbs/17c7f05b50.jpg +0 -0
  186. package/kb/references/thumbs/17f97e61dd.jpg +0 -0
  187. package/kb/references/thumbs/1804e17365.jpg +0 -0
  188. package/kb/references/thumbs/195c162ed1.jpg +0 -0
  189. package/kb/references/thumbs/19e3699c29.jpg +0 -0
  190. package/kb/references/thumbs/1a48e0a4d5.jpg +0 -0
  191. package/kb/references/thumbs/1a6cda265e.jpg +0 -0
  192. package/kb/references/thumbs/1a7e012091.jpg +0 -0
  193. package/kb/references/thumbs/1a9173a172.jpg +0 -0
  194. package/kb/references/thumbs/1b34a6b4d2.jpg +0 -0
  195. package/kb/references/thumbs/1b5284f3b2.jpg +0 -0
  196. package/kb/references/thumbs/1b93fbd6d3.jpg +0 -0
  197. package/kb/references/thumbs/1c6728fef6.jpg +0 -0
  198. package/kb/references/thumbs/1c6f101618.jpg +0 -0
  199. package/kb/references/thumbs/1cf7011a94.jpg +0 -0
  200. package/kb/references/thumbs/1d1769e539.jpg +0 -0
  201. package/kb/references/thumbs/1d1fd23199.jpg +0 -0
  202. package/kb/references/thumbs/1d7b6602ac.jpg +0 -0
  203. package/kb/references/thumbs/1dbbc61b9e.jpg +0 -0
  204. package/kb/references/thumbs/1dbdd6d21d.jpg +0 -0
  205. package/kb/references/thumbs/1dd873744b.jpg +0 -0
  206. package/kb/references/thumbs/1df4dd7cf9.jpg +0 -0
  207. package/kb/references/thumbs/1ece8c35b8.jpg +0 -0
  208. package/kb/references/thumbs/1f0e5b9aa4.jpg +0 -0
  209. package/kb/references/thumbs/1f1419651b.jpg +0 -0
  210. package/kb/references/thumbs/1f2a6da92d.jpg +0 -0
  211. package/kb/references/thumbs/1f651f33c0.jpg +0 -0
  212. package/kb/references/thumbs/1f8b697d18.jpg +0 -0
  213. package/kb/references/thumbs/1fee3824cd.jpg +0 -0
  214. package/kb/references/thumbs/1ff7757853.jpg +0 -0
  215. package/kb/references/thumbs/2043182f01.jpg +0 -0
  216. package/kb/references/thumbs/2059dabcd5.jpg +0 -0
  217. package/kb/references/thumbs/209d9dbb95.jpg +0 -0
  218. package/kb/references/thumbs/20d8923554.jpg +0 -0
  219. package/kb/references/thumbs/210dd3b671.jpg +0 -0
  220. package/kb/references/thumbs/2120719116.jpg +0 -0
  221. package/kb/references/thumbs/21a7384429.jpg +0 -0
  222. package/kb/references/thumbs/225f187391.jpg +0 -0
  223. package/kb/references/thumbs/230caf7dd2.jpg +0 -0
  224. package/kb/references/thumbs/235afad355.jpg +0 -0
  225. package/kb/references/thumbs/2378066687.jpg +0 -0
  226. package/kb/references/thumbs/241faf68a1.jpg +0 -0
  227. package/kb/references/thumbs/24d9f1a9b9.jpg +0 -0
  228. package/kb/references/thumbs/2505c8b9a0.jpg +0 -0
  229. package/kb/references/thumbs/25593638ca.jpg +0 -0
  230. package/kb/references/thumbs/25dd454f18.jpg +0 -0
  231. package/kb/references/thumbs/2602e123e2.jpg +0 -0
  232. package/kb/references/thumbs/26095c0b11.jpg +0 -0
  233. package/kb/references/thumbs/26260d6238.jpg +0 -0
  234. package/kb/references/thumbs/26bba21836.jpg +0 -0
  235. package/kb/references/thumbs/26bde3520b.jpg +0 -0
  236. package/kb/references/thumbs/272e22067c.jpg +0 -0
  237. package/kb/references/thumbs/27421b3226.jpg +0 -0
  238. package/kb/references/thumbs/2769ebca41.jpg +0 -0
  239. package/kb/references/thumbs/27e2f6e8b0.jpg +0 -0
  240. package/kb/references/thumbs/28432ba373.jpg +0 -0
  241. package/kb/references/thumbs/2846626f19.jpg +0 -0
  242. package/kb/references/thumbs/28750e821e.jpg +0 -0
  243. package/kb/references/thumbs/289481230f.jpg +0 -0
  244. package/kb/references/thumbs/2897967408.jpg +0 -0
  245. package/kb/references/thumbs/28c0a5fa17.jpg +0 -0
  246. package/kb/references/thumbs/28c181fba5.jpg +0 -0
  247. package/kb/references/thumbs/29806f539f.jpg +0 -0
  248. package/kb/references/thumbs/2997f56ee7.jpg +0 -0
  249. package/kb/references/thumbs/29b34a10fb.jpg +0 -0
  250. package/kb/references/thumbs/29bd78cdb4.jpg +0 -0
  251. package/kb/references/thumbs/29c8c6c22a.jpg +0 -0
  252. package/kb/references/thumbs/2ac23f7fae.jpg +0 -0
  253. package/kb/references/thumbs/2b76e13f79.jpg +0 -0
  254. package/kb/references/thumbs/2b84226479.jpg +0 -0
  255. package/kb/references/thumbs/2bf2dbf737.jpg +0 -0
  256. package/kb/references/thumbs/2bf89abbf6.jpg +0 -0
  257. package/kb/references/thumbs/2c562cd534.jpg +0 -0
  258. package/kb/references/thumbs/2cfe9d48c4.jpg +0 -0
  259. package/kb/references/thumbs/2d49f19b27.jpg +0 -0
  260. package/kb/references/thumbs/2dc8670da7.jpg +0 -0
  261. package/kb/references/thumbs/2de947c469.jpg +0 -0
  262. package/kb/references/thumbs/2e075ca79f.jpg +0 -0
  263. package/kb/references/thumbs/2eb0820e6b.jpg +0 -0
  264. package/kb/references/thumbs/2ec56a9dc0.jpg +0 -0
  265. package/kb/references/thumbs/30bb4ad607.jpg +0 -0
  266. package/kb/references/thumbs/30d19b8444.jpg +0 -0
  267. package/kb/references/thumbs/3173c71d73.jpg +0 -0
  268. package/kb/references/thumbs/31e59e4518.jpg +0 -0
  269. package/kb/references/thumbs/322b8960e8.jpg +0 -0
  270. package/kb/references/thumbs/325c9be6f4.jpg +0 -0
  271. package/kb/references/thumbs/3273338982.jpg +0 -0
  272. package/kb/references/thumbs/32f8387f38.jpg +0 -0
  273. package/kb/references/thumbs/332f6b74c3.jpg +0 -0
  274. package/kb/references/thumbs/334b146b6a.jpg +0 -0
  275. package/kb/references/thumbs/339abd60e4.jpg +0 -0
  276. package/kb/references/thumbs/33e88ec295.jpg +0 -0
  277. package/kb/references/thumbs/340931b5d4.jpg +0 -0
  278. package/kb/references/thumbs/34165a93bd.jpg +0 -0
  279. package/kb/references/thumbs/34d0575dd7.jpg +0 -0
  280. package/kb/references/thumbs/3549e75f0a.jpg +0 -0
  281. package/kb/references/thumbs/35827f1561.jpg +0 -0
  282. package/kb/references/thumbs/37c50280ec.jpg +0 -0
  283. package/kb/references/thumbs/37e66ecf11.jpg +0 -0
  284. package/kb/references/thumbs/3841a04fdc.jpg +0 -0
  285. package/kb/references/thumbs/38490c088f.jpg +0 -0
  286. package/kb/references/thumbs/3850319e9f.jpg +0 -0
  287. package/kb/references/thumbs/3856a64166.jpg +0 -0
  288. package/kb/references/thumbs/3881f211c2.jpg +0 -0
  289. package/kb/references/thumbs/38c7dde2f3.jpg +0 -0
  290. package/kb/references/thumbs/38ce9e486c.jpg +0 -0
  291. package/kb/references/thumbs/390ea8e01b.jpg +0 -0
  292. package/kb/references/thumbs/391b7fc27c.jpg +0 -0
  293. package/kb/references/thumbs/395f7214dc.jpg +0 -0
  294. package/kb/references/thumbs/39e81a5db2.jpg +0 -0
  295. package/kb/references/thumbs/3a67c8d655.jpg +0 -0
  296. package/kb/references/thumbs/3a89c25e3b.jpg +0 -0
  297. package/kb/references/thumbs/3aa0837542.jpg +0 -0
  298. package/kb/references/thumbs/3aae18aa8a.jpg +0 -0
  299. package/kb/references/thumbs/3bdbe767db.jpg +0 -0
  300. package/kb/references/thumbs/3c0616fc7f.jpg +0 -0
  301. package/kb/references/thumbs/3c18e9db1d.jpg +0 -0
  302. package/kb/references/thumbs/3c5a14076c.jpg +0 -0
  303. package/kb/references/thumbs/3ca9ec15c6.jpg +0 -0
  304. package/kb/references/thumbs/3d7756fd35.jpg +0 -0
  305. package/kb/references/thumbs/3dd45bd33d.jpg +0 -0
  306. package/kb/references/thumbs/3de72d5414.jpg +0 -0
  307. package/kb/references/thumbs/3dede597af.jpg +0 -0
  308. package/kb/references/thumbs/3ea948619e.jpg +0 -0
  309. package/kb/references/thumbs/402c12054c.jpg +0 -0
  310. package/kb/references/thumbs/403ab4af90.jpg +0 -0
  311. package/kb/references/thumbs/40773a8c63.jpg +0 -0
  312. package/kb/references/thumbs/407d9583db.jpg +0 -0
  313. package/kb/references/thumbs/415dc1d84c.jpg +0 -0
  314. package/kb/references/thumbs/420b676a4f.jpg +0 -0
  315. package/kb/references/thumbs/42a1ee85eb.jpg +0 -0
  316. package/kb/references/thumbs/43059221aa.jpg +0 -0
  317. package/kb/references/thumbs/432fa0d22c.jpg +0 -0
  318. package/kb/references/thumbs/43826f20e3.jpg +0 -0
  319. package/kb/references/thumbs/43ac55c98a.jpg +0 -0
  320. package/kb/references/thumbs/43d098f3c3.jpg +0 -0
  321. package/kb/references/thumbs/4449d80c2c.jpg +0 -0
  322. package/kb/references/thumbs/44625727dc.jpg +0 -0
  323. package/kb/references/thumbs/4482bb87a1.jpg +0 -0
  324. package/kb/references/thumbs/44b078acdc.jpg +0 -0
  325. package/kb/references/thumbs/44d0305850.jpg +0 -0
  326. package/kb/references/thumbs/456bb6b59e.jpg +0 -0
  327. package/kb/references/thumbs/45df73ce48.jpg +0 -0
  328. package/kb/references/thumbs/45edb45d87.jpg +0 -0
  329. package/kb/references/thumbs/4671d57cd5.jpg +0 -0
  330. package/kb/references/thumbs/46cc108518.jpg +0 -0
  331. package/kb/references/thumbs/470022e614.jpg +0 -0
  332. package/kb/references/thumbs/4719a669ce.jpg +0 -0
  333. package/kb/references/thumbs/47404f5ad4.jpg +0 -0
  334. package/kb/references/thumbs/479d3eb977.jpg +0 -0
  335. package/kb/references/thumbs/479ed92ceb.jpg +0 -0
  336. package/kb/references/thumbs/47baed50c7.jpg +0 -0
  337. package/kb/references/thumbs/47fdcfd7b0.jpg +0 -0
  338. package/kb/references/thumbs/48fd5e94c5.jpg +0 -0
  339. package/kb/references/thumbs/493807917b.jpg +0 -0
  340. package/kb/references/thumbs/49a1c67293.jpg +0 -0
  341. package/kb/references/thumbs/49e2d7fafe.jpg +0 -0
  342. package/kb/references/thumbs/49f540fcb6.jpg +0 -0
  343. package/kb/references/thumbs/4a67ec4028.jpg +0 -0
  344. package/kb/references/thumbs/4a93275b04.jpg +0 -0
  345. package/kb/references/thumbs/4aae6f52da.jpg +0 -0
  346. package/kb/references/thumbs/4b1bae66bd.jpg +0 -0
  347. package/kb/references/thumbs/4b75da15a9.jpg +0 -0
  348. package/kb/references/thumbs/4b77789b88.jpg +0 -0
  349. package/kb/references/thumbs/4bc2f3f3a4.jpg +0 -0
  350. package/kb/references/thumbs/4c48f27b88.jpg +0 -0
  351. package/kb/references/thumbs/4c52bd8bfb.jpg +0 -0
  352. package/kb/references/thumbs/4c9b4acc60.jpg +0 -0
  353. package/kb/references/thumbs/4cbf0752d4.jpg +0 -0
  354. package/kb/references/thumbs/4cd2672517.jpg +0 -0
  355. package/kb/references/thumbs/4dd22a8181.jpg +0 -0
  356. package/kb/references/thumbs/4ee3447314.jpg +0 -0
  357. package/kb/references/thumbs/4ef29c8144.jpg +0 -0
  358. package/kb/references/thumbs/4f82fadfd6.jpg +0 -0
  359. package/kb/references/thumbs/4fc9b643d8.jpg +0 -0
  360. package/kb/references/thumbs/50549524b0.jpg +0 -0
  361. package/kb/references/thumbs/509a615883.jpg +0 -0
  362. package/kb/references/thumbs/50c03ef855.jpg +0 -0
  363. package/kb/references/thumbs/50cf9acad6.jpg +0 -0
  364. package/kb/references/thumbs/51131b025c.jpg +0 -0
  365. package/kb/references/thumbs/51da1124f0.jpg +0 -0
  366. package/kb/references/thumbs/524d5e7288.jpg +0 -0
  367. package/kb/references/thumbs/5251247c81.jpg +0 -0
  368. package/kb/references/thumbs/526b91b7ec.jpg +0 -0
  369. package/kb/references/thumbs/52f02f6c2e.jpg +0 -0
  370. package/kb/references/thumbs/530ab530dc.jpg +0 -0
  371. package/kb/references/thumbs/5335ee4404.jpg +0 -0
  372. package/kb/references/thumbs/5385b90939.jpg +0 -0
  373. package/kb/references/thumbs/53c54654cc.jpg +0 -0
  374. package/kb/references/thumbs/5420794c6c.jpg +0 -0
  375. package/kb/references/thumbs/542a9dce8b.jpg +0 -0
  376. package/kb/references/thumbs/5571530a5f.jpg +0 -0
  377. package/kb/references/thumbs/56cc68d804.jpg +0 -0
  378. package/kb/references/thumbs/577b7f7c44.jpg +0 -0
  379. package/kb/references/thumbs/57adf8f1bb.jpg +0 -0
  380. package/kb/references/thumbs/57c5f5e3a6.jpg +0 -0
  381. package/kb/references/thumbs/57ffe4e839.jpg +0 -0
  382. package/kb/references/thumbs/5817dfc7d6.jpg +0 -0
  383. package/kb/references/thumbs/581a182a7a.jpg +0 -0
  384. package/kb/references/thumbs/58807679c8.jpg +0 -0
  385. package/kb/references/thumbs/589a1d9111.jpg +0 -0
  386. package/kb/references/thumbs/59167391ab.jpg +0 -0
  387. package/kb/references/thumbs/5a70420166.jpg +0 -0
  388. package/kb/references/thumbs/5b10df1b8d.jpg +0 -0
  389. package/kb/references/thumbs/5b7a712fdb.jpg +0 -0
  390. package/kb/references/thumbs/5bc46b92ad.jpg +0 -0
  391. package/kb/references/thumbs/5c0546a120.jpg +0 -0
  392. package/kb/references/thumbs/5c2b6125a8.jpg +0 -0
  393. package/kb/references/thumbs/5c536b9e42.jpg +0 -0
  394. package/kb/references/thumbs/5c8e441d30.jpg +0 -0
  395. package/kb/references/thumbs/5cb6f1baa5.jpg +0 -0
  396. package/kb/references/thumbs/5ce6c1f21e.jpg +0 -0
  397. package/kb/references/thumbs/5cef052c0a.jpg +0 -0
  398. package/kb/references/thumbs/5d08438209.jpg +0 -0
  399. package/kb/references/thumbs/5d0f9af9b0.jpg +0 -0
  400. package/kb/references/thumbs/5d300ed332.jpg +0 -0
  401. package/kb/references/thumbs/5d708a4794.jpg +0 -0
  402. package/kb/references/thumbs/5de8826a98.jpg +0 -0
  403. package/kb/references/thumbs/5df7bff22c.jpg +0 -0
  404. package/kb/references/thumbs/5e087d0f00.jpg +0 -0
  405. package/kb/references/thumbs/5e44e70ca0.jpg +0 -0
  406. package/kb/references/thumbs/5eff5d80ce.jpg +0 -0
  407. package/kb/references/thumbs/5f015c96b1.jpg +0 -0
  408. package/kb/references/thumbs/5f3289b7c4.jpg +0 -0
  409. package/kb/references/thumbs/5f830c40eb.jpg +0 -0
  410. package/kb/references/thumbs/60c4c3527b.jpg +0 -0
  411. package/kb/references/thumbs/60e9e594dd.jpg +0 -0
  412. package/kb/references/thumbs/615421d7be.jpg +0 -0
  413. package/kb/references/thumbs/6176e4004f.jpg +0 -0
  414. package/kb/references/thumbs/61f0fe5e06.jpg +0 -0
  415. package/kb/references/thumbs/6213785db4.jpg +0 -0
  416. package/kb/references/thumbs/62a670ae09.jpg +0 -0
  417. package/kb/references/thumbs/63cc12981b.jpg +0 -0
  418. package/kb/references/thumbs/641f15cc71.jpg +0 -0
  419. package/kb/references/thumbs/642ab2081d.jpg +0 -0
  420. package/kb/references/thumbs/644a59bf6e.jpg +0 -0
  421. package/kb/references/thumbs/644da8a8e3.jpg +0 -0
  422. package/kb/references/thumbs/6458986897.jpg +0 -0
  423. package/kb/references/thumbs/64cfbd7b93.jpg +0 -0
  424. package/kb/references/thumbs/661a57686a.jpg +0 -0
  425. package/kb/references/thumbs/66355a813a.jpg +0 -0
  426. package/kb/references/thumbs/664396dd44.jpg +0 -0
  427. package/kb/references/thumbs/66d832d65f.jpg +0 -0
  428. package/kb/references/thumbs/66de9e9833.jpg +0 -0
  429. package/kb/references/thumbs/6750964fd9.jpg +0 -0
  430. package/kb/references/thumbs/67c7164832.jpg +0 -0
  431. package/kb/references/thumbs/681600b6cd.jpg +0 -0
  432. package/kb/references/thumbs/682e75afe4.jpg +0 -0
  433. package/kb/references/thumbs/69274bc952.jpg +0 -0
  434. package/kb/references/thumbs/696ec08c52.jpg +0 -0
  435. package/kb/references/thumbs/69760111cc.jpg +0 -0
  436. package/kb/references/thumbs/697e32664f.jpg +0 -0
  437. package/kb/references/thumbs/69df92544b.jpg +0 -0
  438. package/kb/references/thumbs/69e48c5229.jpg +0 -0
  439. package/kb/references/thumbs/6a5fb75de1.jpg +0 -0
  440. package/kb/references/thumbs/6a7389e472.jpg +0 -0
  441. package/kb/references/thumbs/6a9b041e02.jpg +0 -0
  442. package/kb/references/thumbs/6ab2fa93ee.jpg +0 -0
  443. package/kb/references/thumbs/6ae3400319.jpg +0 -0
  444. package/kb/references/thumbs/6b21344bf3.jpg +0 -0
  445. package/kb/references/thumbs/6bd1b67a8a.jpg +0 -0
  446. package/kb/references/thumbs/6c99156c4f.jpg +0 -0
  447. package/kb/references/thumbs/6cc9b65384.jpg +0 -0
  448. package/kb/references/thumbs/6d15830a73.jpg +0 -0
  449. package/kb/references/thumbs/6d4c33d180.jpg +0 -0
  450. package/kb/references/thumbs/6e36815799.jpg +0 -0
  451. package/kb/references/thumbs/6eddb9cbfc.jpg +0 -0
  452. package/kb/references/thumbs/6f97968a7c.jpg +0 -0
  453. package/kb/references/thumbs/6fa0716a7d.jpg +0 -0
  454. package/kb/references/thumbs/6facd57184.jpg +0 -0
  455. package/kb/references/thumbs/6fd57b785a.jpg +0 -0
  456. package/kb/references/thumbs/700b3cb185.jpg +0 -0
  457. package/kb/references/thumbs/71197443fa.jpg +0 -0
  458. package/kb/references/thumbs/72082bc9ee.jpg +0 -0
  459. package/kb/references/thumbs/72453db479.jpg +0 -0
  460. package/kb/references/thumbs/72cd64d500.jpg +0 -0
  461. package/kb/references/thumbs/72d1e75283.jpg +0 -0
  462. package/kb/references/thumbs/72e13f7f5b.jpg +0 -0
  463. package/kb/references/thumbs/7459f8e644.jpg +0 -0
  464. package/kb/references/thumbs/7470d55c79.jpg +0 -0
  465. package/kb/references/thumbs/74c468f02a.jpg +0 -0
  466. package/kb/references/thumbs/74fed6eb77.jpg +0 -0
  467. package/kb/references/thumbs/756e05080d.jpg +0 -0
  468. package/kb/references/thumbs/75df7e5f49.jpg +0 -0
  469. package/kb/references/thumbs/76226da6ef.jpg +0 -0
  470. package/kb/references/thumbs/76a34aa056.jpg +0 -0
  471. package/kb/references/thumbs/76c7076589.jpg +0 -0
  472. package/kb/references/thumbs/76cccc396c.jpg +0 -0
  473. package/kb/references/thumbs/774654789f.jpg +0 -0
  474. package/kb/references/thumbs/7746cc494d.jpg +0 -0
  475. package/kb/references/thumbs/7766e36aa3.jpg +0 -0
  476. package/kb/references/thumbs/78049331ef.jpg +0 -0
  477. package/kb/references/thumbs/78808a0ca5.jpg +0 -0
  478. package/kb/references/thumbs/7890b0a310.jpg +0 -0
  479. package/kb/references/thumbs/78911975e6.jpg +0 -0
  480. package/kb/references/thumbs/78d289d5a0.jpg +0 -0
  481. package/kb/references/thumbs/794126267e.jpg +0 -0
  482. package/kb/references/thumbs/7976eb9025.jpg +0 -0
  483. package/kb/references/thumbs/798519ec7c.jpg +0 -0
  484. package/kb/references/thumbs/7a6cc5eecc.jpg +0 -0
  485. package/kb/references/thumbs/7ac8a7cd4a.jpg +0 -0
  486. package/kb/references/thumbs/7b1341d61d.jpg +0 -0
  487. package/kb/references/thumbs/7b9c4031e3.jpg +0 -0
  488. package/kb/references/thumbs/7ba5ad3779.jpg +0 -0
  489. package/kb/references/thumbs/7be6eb2bd0.jpg +0 -0
  490. package/kb/references/thumbs/7c30864f5d.jpg +0 -0
  491. package/kb/references/thumbs/7c379b5e17.jpg +0 -0
  492. package/kb/references/thumbs/7c4050eb02.jpg +0 -0
  493. package/kb/references/thumbs/7ca47b3221.jpg +0 -0
  494. package/kb/references/thumbs/7d43516bd7.jpg +0 -0
  495. package/kb/references/thumbs/7da9dbb98a.jpg +0 -0
  496. package/kb/references/thumbs/7ddda8f57c.jpg +0 -0
  497. package/kb/references/thumbs/7ece6d215c.jpg +0 -0
  498. package/kb/references/thumbs/7eebf029de.jpg +0 -0
  499. package/kb/references/thumbs/7f45483c15.jpg +0 -0
  500. package/kb/references/thumbs/7fde06eaa5.jpg +0 -0
  501. package/kb/references/thumbs/80407b97e9.jpg +0 -0
  502. package/kb/references/thumbs/80f0a6cf4b.jpg +0 -0
  503. package/kb/references/thumbs/8118b451b8.jpg +0 -0
  504. package/kb/references/thumbs/816baed4a8.jpg +0 -0
  505. package/kb/references/thumbs/817d2d15d9.jpg +0 -0
  506. package/kb/references/thumbs/8260cb430d.jpg +0 -0
  507. package/kb/references/thumbs/827b562899.jpg +0 -0
  508. package/kb/references/thumbs/8293cf1e92.jpg +0 -0
  509. package/kb/references/thumbs/8294653169.jpg +0 -0
  510. package/kb/references/thumbs/83061f871f.jpg +0 -0
  511. package/kb/references/thumbs/833df778ca.jpg +0 -0
  512. package/kb/references/thumbs/83d3acaafc.jpg +0 -0
  513. package/kb/references/thumbs/844c6efeff.jpg +0 -0
  514. package/kb/references/thumbs/84563d482a.jpg +0 -0
  515. package/kb/references/thumbs/848082dd94.jpg +0 -0
  516. package/kb/references/thumbs/8494b1da94.jpg +0 -0
  517. package/kb/references/thumbs/84b71014be.jpg +0 -0
  518. package/kb/references/thumbs/84be4a5f3c.jpg +0 -0
  519. package/kb/references/thumbs/84fd0fd25a.jpg +0 -0
  520. package/kb/references/thumbs/853e488e22.jpg +0 -0
  521. package/kb/references/thumbs/858c91585d.jpg +0 -0
  522. package/kb/references/thumbs/85903e8fe9.jpg +0 -0
  523. package/kb/references/thumbs/86597d43fd.jpg +0 -0
  524. package/kb/references/thumbs/866eb99500.jpg +0 -0
  525. package/kb/references/thumbs/86ac050340.jpg +0 -0
  526. package/kb/references/thumbs/86afec7b64.jpg +0 -0
  527. package/kb/references/thumbs/87238127f5.jpg +0 -0
  528. package/kb/references/thumbs/87321df792.jpg +0 -0
  529. package/kb/references/thumbs/87460f70cd.jpg +0 -0
  530. package/kb/references/thumbs/8770dfeaec.jpg +0 -0
  531. package/kb/references/thumbs/8798451a8c.jpg +0 -0
  532. package/kb/references/thumbs/87da1f34a3.jpg +0 -0
  533. package/kb/references/thumbs/882cdf8c05.jpg +0 -0
  534. package/kb/references/thumbs/8839f4106f.jpg +0 -0
  535. package/kb/references/thumbs/8844455212.jpg +0 -0
  536. package/kb/references/thumbs/88abba0497.jpg +0 -0
  537. package/kb/references/thumbs/88b372435d.jpg +0 -0
  538. package/kb/references/thumbs/88bd4f35fa.jpg +0 -0
  539. package/kb/references/thumbs/88c71a075e.jpg +0 -0
  540. package/kb/references/thumbs/892e5f048e.jpg +0 -0
  541. package/kb/references/thumbs/893cc8e920.jpg +0 -0
  542. package/kb/references/thumbs/896c198814.jpg +0 -0
  543. package/kb/references/thumbs/8a8e22f756.jpg +0 -0
  544. package/kb/references/thumbs/8a9d8a35cb.jpg +0 -0
  545. package/kb/references/thumbs/8ab1d67fec.jpg +0 -0
  546. package/kb/references/thumbs/8ab201fe12.jpg +0 -0
  547. package/kb/references/thumbs/8ac372c102.jpg +0 -0
  548. package/kb/references/thumbs/8b15736d62.jpg +0 -0
  549. package/kb/references/thumbs/8b1d8d6a05.jpg +0 -0
  550. package/kb/references/thumbs/8bb6050e20.jpg +0 -0
  551. package/kb/references/thumbs/8c6c9d1577.jpg +0 -0
  552. package/kb/references/thumbs/8d337851e0.jpg +0 -0
  553. package/kb/references/thumbs/8d44b33cc5.jpg +0 -0
  554. package/kb/references/thumbs/8d4536ff3b.jpg +0 -0
  555. package/kb/references/thumbs/8d9b99454a.jpg +0 -0
  556. package/kb/references/thumbs/8dda2303ff.jpg +0 -0
  557. package/kb/references/thumbs/8dec722d62.jpg +0 -0
  558. package/kb/references/thumbs/8e0ea4ca74.jpg +0 -0
  559. package/kb/references/thumbs/8ebba84d69.jpg +0 -0
  560. package/kb/references/thumbs/8ef09140c0.jpg +0 -0
  561. package/kb/references/thumbs/8f2928a1b0.jpg +0 -0
  562. package/kb/references/thumbs/8f88f9ddba.jpg +0 -0
  563. package/kb/references/thumbs/8fa4451009.jpg +0 -0
  564. package/kb/references/thumbs/90109b0e02.jpg +0 -0
  565. package/kb/references/thumbs/90e854e5c9.jpg +0 -0
  566. package/kb/references/thumbs/914e79f893.jpg +0 -0
  567. package/kb/references/thumbs/9161369197.jpg +0 -0
  568. package/kb/references/thumbs/9165c87d6f.jpg +0 -0
  569. package/kb/references/thumbs/9193f6558a.jpg +0 -0
  570. package/kb/references/thumbs/91ac7df877.jpg +0 -0
  571. package/kb/references/thumbs/9216bd4931.jpg +0 -0
  572. package/kb/references/thumbs/922ae8a26a.jpg +0 -0
  573. package/kb/references/thumbs/92a70e6867.jpg +0 -0
  574. package/kb/references/thumbs/92c77ece22.jpg +0 -0
  575. package/kb/references/thumbs/92e5914fbf.jpg +0 -0
  576. package/kb/references/thumbs/92ee36419a.jpg +0 -0
  577. package/kb/references/thumbs/94153a60b0.jpg +0 -0
  578. package/kb/references/thumbs/9449666640.jpg +0 -0
  579. package/kb/references/thumbs/944be6a646.jpg +0 -0
  580. package/kb/references/thumbs/945831476e.jpg +0 -0
  581. package/kb/references/thumbs/94c1af82f4.jpg +0 -0
  582. package/kb/references/thumbs/9599d6d589.jpg +0 -0
  583. package/kb/references/thumbs/960710f4cf.jpg +0 -0
  584. package/kb/references/thumbs/9608f12d64.jpg +0 -0
  585. package/kb/references/thumbs/962dabfaa0.jpg +0 -0
  586. package/kb/references/thumbs/965a3a95c6.jpg +0 -0
  587. package/kb/references/thumbs/9672ed5ae4.jpg +0 -0
  588. package/kb/references/thumbs/96891ae9c1.jpg +0 -0
  589. package/kb/references/thumbs/96e1907c99.jpg +0 -0
  590. package/kb/references/thumbs/96f25b6578.jpg +0 -0
  591. package/kb/references/thumbs/97211df9ae.jpg +0 -0
  592. package/kb/references/thumbs/9769d763a5.jpg +0 -0
  593. package/kb/references/thumbs/9792bd6d05.jpg +0 -0
  594. package/kb/references/thumbs/97d2837e3e.jpg +0 -0
  595. package/kb/references/thumbs/97d90d1d85.jpg +0 -0
  596. package/kb/references/thumbs/97fcd5774a.jpg +0 -0
  597. package/kb/references/thumbs/980f68564e.jpg +0 -0
  598. package/kb/references/thumbs/98511fb393.jpg +0 -0
  599. package/kb/references/thumbs/98f8fa694d.jpg +0 -0
  600. package/kb/references/thumbs/9a42c6bb97.jpg +0 -0
  601. package/kb/references/thumbs/9ab3600a06.jpg +0 -0
  602. package/kb/references/thumbs/9ab48d9130.jpg +0 -0
  603. package/kb/references/thumbs/9ad519197d.jpg +0 -0
  604. package/kb/references/thumbs/9b1382c657.jpg +0 -0
  605. package/kb/references/thumbs/9b6cc1e077.jpg +0 -0
  606. package/kb/references/thumbs/9beede8480.jpg +0 -0
  607. package/kb/references/thumbs/9bf8976952.jpg +0 -0
  608. package/kb/references/thumbs/9cace7b75a.jpg +0 -0
  609. package/kb/references/thumbs/9cdcc5745e.jpg +0 -0
  610. package/kb/references/thumbs/9d0b269074.jpg +0 -0
  611. package/kb/references/thumbs/9d41788b48.jpg +0 -0
  612. package/kb/references/thumbs/9d475bd6af.jpg +0 -0
  613. package/kb/references/thumbs/9e6bc543d0.jpg +0 -0
  614. package/kb/references/thumbs/9ed0e0a206.jpg +0 -0
  615. package/kb/references/thumbs/9ef538de13.jpg +0 -0
  616. package/kb/references/thumbs/9efc373ab4.jpg +0 -0
  617. package/kb/references/thumbs/9f4a939fa5.jpg +0 -0
  618. package/kb/references/thumbs/9f8d12d7b9.jpg +0 -0
  619. package/kb/references/thumbs/9fd5e3efa7.jpg +0 -0
  620. package/kb/references/thumbs/a0a9b9c572.jpg +0 -0
  621. package/kb/references/thumbs/a29ca9b185.jpg +0 -0
  622. package/kb/references/thumbs/a2a42884af.jpg +0 -0
  623. package/kb/references/thumbs/a2f5064e34.jpg +0 -0
  624. package/kb/references/thumbs/a3112ce672.jpg +0 -0
  625. package/kb/references/thumbs/a3437736d1.jpg +0 -0
  626. package/kb/references/thumbs/a372667e76.jpg +0 -0
  627. package/kb/references/thumbs/a39bae94e9.jpg +0 -0
  628. package/kb/references/thumbs/a3fc75d94c.jpg +0 -0
  629. package/kb/references/thumbs/a440487631.jpg +0 -0
  630. package/kb/references/thumbs/a4993d5fb5.jpg +0 -0
  631. package/kb/references/thumbs/a500476a86.jpg +0 -0
  632. package/kb/references/thumbs/a55506bf46.jpg +0 -0
  633. package/kb/references/thumbs/a59a5f9843.jpg +0 -0
  634. package/kb/references/thumbs/a5bce0eede.jpg +0 -0
  635. package/kb/references/thumbs/a5d921e1f0.jpg +0 -0
  636. package/kb/references/thumbs/a5e19d4637.jpg +0 -0
  637. package/kb/references/thumbs/a5f92b97d5.jpg +0 -0
  638. package/kb/references/thumbs/a6562c210a.jpg +0 -0
  639. package/kb/references/thumbs/a6f61a8d09.jpg +0 -0
  640. package/kb/references/thumbs/a719fdf663.jpg +0 -0
  641. package/kb/references/thumbs/a724e5442f.jpg +0 -0
  642. package/kb/references/thumbs/a852b4319f.jpg +0 -0
  643. package/kb/references/thumbs/a896c08448.jpg +0 -0
  644. package/kb/references/thumbs/a8d190a4b8.jpg +0 -0
  645. package/kb/references/thumbs/a8dcdd57d7.jpg +0 -0
  646. package/kb/references/thumbs/a951347e18.jpg +0 -0
  647. package/kb/references/thumbs/a9a1a0bf7d.jpg +0 -0
  648. package/kb/references/thumbs/a9b72986a8.jpg +0 -0
  649. package/kb/references/thumbs/aa11555ba8.jpg +0 -0
  650. package/kb/references/thumbs/aa4aab9093.jpg +0 -0
  651. package/kb/references/thumbs/aa89b96658.jpg +0 -0
  652. package/kb/references/thumbs/aaeb3739b6.jpg +0 -0
  653. package/kb/references/thumbs/ab25a608ec.jpg +0 -0
  654. package/kb/references/thumbs/ab34b62268.jpg +0 -0
  655. package/kb/references/thumbs/ab3b0217bb.jpg +0 -0
  656. package/kb/references/thumbs/ac70c04afb.jpg +0 -0
  657. package/kb/references/thumbs/ace0879117.jpg +0 -0
  658. package/kb/references/thumbs/ad0515ae55.jpg +0 -0
  659. package/kb/references/thumbs/ad66b25b47.jpg +0 -0
  660. package/kb/references/thumbs/ad68229d2f.jpg +0 -0
  661. package/kb/references/thumbs/add6480693.jpg +0 -0
  662. package/kb/references/thumbs/ae5b8c3b8c.jpg +0 -0
  663. package/kb/references/thumbs/ae81247b47.jpg +0 -0
  664. package/kb/references/thumbs/af8aa54379.jpg +0 -0
  665. package/kb/references/thumbs/afa80dedd4.jpg +0 -0
  666. package/kb/references/thumbs/afe04d730f.jpg +0 -0
  667. package/kb/references/thumbs/b09a7dc315.jpg +0 -0
  668. package/kb/references/thumbs/b0c02629f6.jpg +0 -0
  669. package/kb/references/thumbs/b1cb4f91de.jpg +0 -0
  670. package/kb/references/thumbs/b1d4c13c92.jpg +0 -0
  671. package/kb/references/thumbs/b20eef86db.jpg +0 -0
  672. package/kb/references/thumbs/b3136ccdde.jpg +0 -0
  673. package/kb/references/thumbs/b3f5e8d7dd.jpg +0 -0
  674. package/kb/references/thumbs/b44adb81c9.jpg +0 -0
  675. package/kb/references/thumbs/b457a5894b.jpg +0 -0
  676. package/kb/references/thumbs/b461281e0f.jpg +0 -0
  677. package/kb/references/thumbs/b4942644f0.jpg +0 -0
  678. package/kb/references/thumbs/b49f8eccad.jpg +0 -0
  679. package/kb/references/thumbs/b56c69be23.jpg +0 -0
  680. package/kb/references/thumbs/b59786b33c.jpg +0 -0
  681. package/kb/references/thumbs/b5d368ce5a.jpg +0 -0
  682. package/kb/references/thumbs/b5eb442406.jpg +0 -0
  683. package/kb/references/thumbs/b7fdabf4ab.jpg +0 -0
  684. package/kb/references/thumbs/b88ee8d235.jpg +0 -0
  685. package/kb/references/thumbs/b8a4e5be8a.jpg +0 -0
  686. package/kb/references/thumbs/b8c85b661c.jpg +0 -0
  687. package/kb/references/thumbs/ba1b8e7435.jpg +0 -0
  688. package/kb/references/thumbs/ba9a593eed.jpg +0 -0
  689. package/kb/references/thumbs/baa7afc201.jpg +0 -0
  690. package/kb/references/thumbs/baf74398f7.jpg +0 -0
  691. package/kb/references/thumbs/bb3e947c10.jpg +0 -0
  692. package/kb/references/thumbs/bb4c1bb7c1.jpg +0 -0
  693. package/kb/references/thumbs/bbaa25cc8a.jpg +0 -0
  694. package/kb/references/thumbs/bc7e7f88cc.jpg +0 -0
  695. package/kb/references/thumbs/bc9d5b58f5.jpg +0 -0
  696. package/kb/references/thumbs/bcc99a51f7.jpg +0 -0
  697. package/kb/references/thumbs/bce13dd1c1.jpg +0 -0
  698. package/kb/references/thumbs/bd079a6130.jpg +0 -0
  699. package/kb/references/thumbs/bd2d70dda7.jpg +0 -0
  700. package/kb/references/thumbs/bd5895c5f2.jpg +0 -0
  701. package/kb/references/thumbs/bde8e31d62.jpg +0 -0
  702. package/kb/references/thumbs/be084e78f3.jpg +0 -0
  703. package/kb/references/thumbs/be94f170c8.jpg +0 -0
  704. package/kb/references/thumbs/bed33654f7.jpg +0 -0
  705. package/kb/references/thumbs/beeb9a0c18.jpg +0 -0
  706. package/kb/references/thumbs/bef42094dc.jpg +0 -0
  707. package/kb/references/thumbs/bf625496fa.jpg +0 -0
  708. package/kb/references/thumbs/bf771f1398.jpg +0 -0
  709. package/kb/references/thumbs/c0ddefa3d9.jpg +0 -0
  710. package/kb/references/thumbs/c0f01e536e.jpg +0 -0
  711. package/kb/references/thumbs/c1d592b678.jpg +0 -0
  712. package/kb/references/thumbs/c1e83e1b3e.jpg +0 -0
  713. package/kb/references/thumbs/c2168f8096.jpg +0 -0
  714. package/kb/references/thumbs/c249cd6b17.jpg +0 -0
  715. package/kb/references/thumbs/c2e3d4feed.jpg +0 -0
  716. package/kb/references/thumbs/c3cec01df1.jpg +0 -0
  717. package/kb/references/thumbs/c47bedb42d.jpg +0 -0
  718. package/kb/references/thumbs/c4b03b4680.jpg +0 -0
  719. package/kb/references/thumbs/c4e6d18ec3.jpg +0 -0
  720. package/kb/references/thumbs/c5477e2877.jpg +0 -0
  721. package/kb/references/thumbs/c566794674.jpg +0 -0
  722. package/kb/references/thumbs/c5b945afbb.jpg +0 -0
  723. package/kb/references/thumbs/c5e4659497.jpg +0 -0
  724. package/kb/references/thumbs/c622786702.jpg +0 -0
  725. package/kb/references/thumbs/c6250649ca.jpg +0 -0
  726. package/kb/references/thumbs/c6261a9526.jpg +0 -0
  727. package/kb/references/thumbs/c62e609076.jpg +0 -0
  728. package/kb/references/thumbs/c6602d3e85.jpg +0 -0
  729. package/kb/references/thumbs/c6c1a16e40.jpg +0 -0
  730. package/kb/references/thumbs/c77f932157.jpg +0 -0
  731. package/kb/references/thumbs/c79788f01e.jpg +0 -0
  732. package/kb/references/thumbs/c7a511ad1e.jpg +0 -0
  733. package/kb/references/thumbs/c7acfbc3c8.jpg +0 -0
  734. package/kb/references/thumbs/c7bb6d1bb8.jpg +0 -0
  735. package/kb/references/thumbs/c829eadcb9.jpg +0 -0
  736. package/kb/references/thumbs/c891fd9d85.jpg +0 -0
  737. package/kb/references/thumbs/c89f812ee3.jpg +0 -0
  738. package/kb/references/thumbs/c8a85b9f61.jpg +0 -0
  739. package/kb/references/thumbs/c8fdf85c9a.jpg +0 -0
  740. package/kb/references/thumbs/c961ac1031.jpg +0 -0
  741. package/kb/references/thumbs/c9fd3f88d8.jpg +0 -0
  742. package/kb/references/thumbs/ca0d6399c3.jpg +0 -0
  743. package/kb/references/thumbs/ca316c83e4.jpg +0 -0
  744. package/kb/references/thumbs/ca3b93aa0d.jpg +0 -0
  745. package/kb/references/thumbs/cb0596a85b.jpg +0 -0
  746. package/kb/references/thumbs/cb95a5c3ef.jpg +0 -0
  747. package/kb/references/thumbs/cbe238b656.jpg +0 -0
  748. package/kb/references/thumbs/cbf530e608.jpg +0 -0
  749. package/kb/references/thumbs/cc173494ec.jpg +0 -0
  750. package/kb/references/thumbs/ccb664bddf.jpg +0 -0
  751. package/kb/references/thumbs/ccf69d36cd.jpg +0 -0
  752. package/kb/references/thumbs/ccf759db42.jpg +0 -0
  753. package/kb/references/thumbs/cd2a285807.jpg +0 -0
  754. package/kb/references/thumbs/cd4a24a8ca.jpg +0 -0
  755. package/kb/references/thumbs/cdb3fe8938.jpg +0 -0
  756. package/kb/references/thumbs/cdce1b9bc6.jpg +0 -0
  757. package/kb/references/thumbs/cde3aa761a.jpg +0 -0
  758. package/kb/references/thumbs/ce9d5cd9c2.jpg +0 -0
  759. package/kb/references/thumbs/ceb01f8a34.jpg +0 -0
  760. package/kb/references/thumbs/cee15c98d0.jpg +0 -0
  761. package/kb/references/thumbs/cf6944ddae.jpg +0 -0
  762. package/kb/references/thumbs/d15dd21cd8.jpg +0 -0
  763. package/kb/references/thumbs/d1633bf2ed.jpg +0 -0
  764. package/kb/references/thumbs/d16a838000.jpg +0 -0
  765. package/kb/references/thumbs/d1cf87e9f0.jpg +0 -0
  766. package/kb/references/thumbs/d32511d913.jpg +0 -0
  767. package/kb/references/thumbs/d327e9dedd.jpg +0 -0
  768. package/kb/references/thumbs/d35bf6c85e.jpg +0 -0
  769. package/kb/references/thumbs/d37afc9d61.jpg +0 -0
  770. package/kb/references/thumbs/d3b66655c7.jpg +0 -0
  771. package/kb/references/thumbs/d40b4172b6.jpg +0 -0
  772. package/kb/references/thumbs/d4c40d1824.jpg +0 -0
  773. package/kb/references/thumbs/d4cb55a4b6.jpg +0 -0
  774. package/kb/references/thumbs/d4f0c30f7e.jpg +0 -0
  775. package/kb/references/thumbs/d51cce3ae5.jpg +0 -0
  776. package/kb/references/thumbs/d52948f9ba.jpg +0 -0
  777. package/kb/references/thumbs/d62bd3cba4.jpg +0 -0
  778. package/kb/references/thumbs/d668a21c92.jpg +0 -0
  779. package/kb/references/thumbs/d685072c52.jpg +0 -0
  780. package/kb/references/thumbs/d6d7b96093.jpg +0 -0
  781. package/kb/references/thumbs/d7cd874b21.jpg +0 -0
  782. package/kb/references/thumbs/d7e111736a.jpg +0 -0
  783. package/kb/references/thumbs/d88325f832.jpg +0 -0
  784. package/kb/references/thumbs/d9272ded76.jpg +0 -0
  785. package/kb/references/thumbs/d962e7c654.jpg +0 -0
  786. package/kb/references/thumbs/d97f000ee2.jpg +0 -0
  787. package/kb/references/thumbs/d9c1ae2543.jpg +0 -0
  788. package/kb/references/thumbs/d9ccb3cac8.jpg +0 -0
  789. package/kb/references/thumbs/da99019680.jpg +0 -0
  790. package/kb/references/thumbs/da9b8bc615.jpg +0 -0
  791. package/kb/references/thumbs/daafbee2c1.jpg +0 -0
  792. package/kb/references/thumbs/db0f11f143.jpg +0 -0
  793. package/kb/references/thumbs/db5dd367f5.jpg +0 -0
  794. package/kb/references/thumbs/db69677866.jpg +0 -0
  795. package/kb/references/thumbs/db87c1956e.jpg +0 -0
  796. package/kb/references/thumbs/dbb86d8bda.jpg +0 -0
  797. package/kb/references/thumbs/dbd3a8ff2a.jpg +0 -0
  798. package/kb/references/thumbs/dbdf4b2794.jpg +0 -0
  799. package/kb/references/thumbs/dd4c19b4b1.jpg +0 -0
  800. package/kb/references/thumbs/dd98d9f6fa.jpg +0 -0
  801. package/kb/references/thumbs/ddb00e3b6a.jpg +0 -0
  802. package/kb/references/thumbs/dde3541a5c.jpg +0 -0
  803. package/kb/references/thumbs/de2a4da502.jpg +0 -0
  804. package/kb/references/thumbs/de4c387a66.jpg +0 -0
  805. package/kb/references/thumbs/de5618fa82.jpg +0 -0
  806. package/kb/references/thumbs/de6bec2538.jpg +0 -0
  807. package/kb/references/thumbs/dea13cbb1f.jpg +0 -0
  808. package/kb/references/thumbs/deff9202aa.jpg +0 -0
  809. package/kb/references/thumbs/df12835f8d.jpg +0 -0
  810. package/kb/references/thumbs/df57acbb3d.jpg +0 -0
  811. package/kb/references/thumbs/df8265009e.jpg +0 -0
  812. package/kb/references/thumbs/dfc99a1e8e.jpg +0 -0
  813. package/kb/references/thumbs/dfc9fde460.jpg +0 -0
  814. package/kb/references/thumbs/e038348eee.jpg +0 -0
  815. package/kb/references/thumbs/e04c2ae1d5.jpg +0 -0
  816. package/kb/references/thumbs/e070345c43.jpg +0 -0
  817. package/kb/references/thumbs/e0a0c84d47.jpg +0 -0
  818. package/kb/references/thumbs/e0af1e6b9e.jpg +0 -0
  819. package/kb/references/thumbs/e1025942c4.jpg +0 -0
  820. package/kb/references/thumbs/e11b6f5b82.jpg +0 -0
  821. package/kb/references/thumbs/e1553de3fc.jpg +0 -0
  822. package/kb/references/thumbs/e162ebe7cd.jpg +0 -0
  823. package/kb/references/thumbs/e1f1e531d8.jpg +0 -0
  824. package/kb/references/thumbs/e20842e5c9.jpg +0 -0
  825. package/kb/references/thumbs/e24e7d949b.jpg +0 -0
  826. package/kb/references/thumbs/e2a3e5d10a.jpg +0 -0
  827. package/kb/references/thumbs/e2de02615a.jpg +0 -0
  828. package/kb/references/thumbs/e305dad7dd.jpg +0 -0
  829. package/kb/references/thumbs/e52e3ef97f.jpg +0 -0
  830. package/kb/references/thumbs/e570e92d06.jpg +0 -0
  831. package/kb/references/thumbs/e583932fb9.jpg +0 -0
  832. package/kb/references/thumbs/e5da3c038f.jpg +0 -0
  833. package/kb/references/thumbs/e657634af3.jpg +0 -0
  834. package/kb/references/thumbs/e67946e3e1.jpg +0 -0
  835. package/kb/references/thumbs/e69e0f3639.jpg +0 -0
  836. package/kb/references/thumbs/e6ebe66031.jpg +0 -0
  837. package/kb/references/thumbs/e7db9563c9.jpg +0 -0
  838. package/kb/references/thumbs/e84a4c3b7b.jpg +0 -0
  839. package/kb/references/thumbs/e84cf52c27.jpg +0 -0
  840. package/kb/references/thumbs/e8707cf6e8.jpg +0 -0
  841. package/kb/references/thumbs/e888890b40.jpg +0 -0
  842. package/kb/references/thumbs/e8a502b418.jpg +0 -0
  843. package/kb/references/thumbs/e8ba68371d.jpg +0 -0
  844. package/kb/references/thumbs/e9098f54cc.jpg +0 -0
  845. package/kb/references/thumbs/e9144deae9.jpg +0 -0
  846. package/kb/references/thumbs/e93e98d757.jpg +0 -0
  847. package/kb/references/thumbs/e9762dd435.jpg +0 -0
  848. package/kb/references/thumbs/e9db4544c8.jpg +0 -0
  849. package/kb/references/thumbs/e9ddffeacd.jpg +0 -0
  850. package/kb/references/thumbs/e9e2cf1d7e.jpg +0 -0
  851. package/kb/references/thumbs/ea1e23e623.jpg +0 -0
  852. package/kb/references/thumbs/ea41200f5e.jpg +0 -0
  853. package/kb/references/thumbs/eaf4916e1a.jpg +0 -0
  854. package/kb/references/thumbs/eb2c505fc8.jpg +0 -0
  855. package/kb/references/thumbs/eb47943693.jpg +0 -0
  856. package/kb/references/thumbs/eb4ffd6625.jpg +0 -0
  857. package/kb/references/thumbs/eb82ac0a4d.jpg +0 -0
  858. package/kb/references/thumbs/ebeb345ade.jpg +0 -0
  859. package/kb/references/thumbs/ec4e2aba0a.jpg +0 -0
  860. package/kb/references/thumbs/ed683bc9f7.jpg +0 -0
  861. package/kb/references/thumbs/ed9e236a84.jpg +0 -0
  862. package/kb/references/thumbs/ee45e1caea.jpg +0 -0
  863. package/kb/references/thumbs/ee50bbf2e7.jpg +0 -0
  864. package/kb/references/thumbs/eed6ad3b7b.jpg +0 -0
  865. package/kb/references/thumbs/eee247fd70.jpg +0 -0
  866. package/kb/references/thumbs/ef87b6cb99.jpg +0 -0
  867. package/kb/references/thumbs/ef87dccf04.jpg +0 -0
  868. package/kb/references/thumbs/ef941bba1e.jpg +0 -0
  869. package/kb/references/thumbs/effc14fb2c.jpg +0 -0
  870. package/kb/references/thumbs/f04aa5a191.jpg +0 -0
  871. package/kb/references/thumbs/f13f12485f.jpg +0 -0
  872. package/kb/references/thumbs/f16a5c0fd4.jpg +0 -0
  873. package/kb/references/thumbs/f1ba7a7a8f.jpg +0 -0
  874. package/kb/references/thumbs/f1bc4c9c19.jpg +0 -0
  875. package/kb/references/thumbs/f1ee827878.jpg +0 -0
  876. package/kb/references/thumbs/f21736aeff.jpg +0 -0
  877. package/kb/references/thumbs/f22bc41213.jpg +0 -0
  878. package/kb/references/thumbs/f266aaffcc.jpg +0 -0
  879. package/kb/references/thumbs/f2c0ed0d65.jpg +0 -0
  880. package/kb/references/thumbs/f32ac710fd.jpg +0 -0
  881. package/kb/references/thumbs/f32b697ff6.jpg +0 -0
  882. package/kb/references/thumbs/f39c883904.jpg +0 -0
  883. package/kb/references/thumbs/f3b9d55e6e.jpg +0 -0
  884. package/kb/references/thumbs/f3c1763e14.jpg +0 -0
  885. package/kb/references/thumbs/f40853fd43.jpg +0 -0
  886. package/kb/references/thumbs/f4fd07dc4d.jpg +0 -0
  887. package/kb/references/thumbs/f50b756ee5.jpg +0 -0
  888. package/kb/references/thumbs/f51272e9b3.jpg +0 -0
  889. package/kb/references/thumbs/f538895eee.jpg +0 -0
  890. package/kb/references/thumbs/f5d28081b5.jpg +0 -0
  891. package/kb/references/thumbs/f6046c9501.jpg +0 -0
  892. package/kb/references/thumbs/f62e4af316.jpg +0 -0
  893. package/kb/references/thumbs/f6f6bffeb8.jpg +0 -0
  894. package/kb/references/thumbs/f736e00af6.jpg +0 -0
  895. package/kb/references/thumbs/f7932da47d.jpg +0 -0
  896. package/kb/references/thumbs/f879e118ca.jpg +0 -0
  897. package/kb/references/thumbs/f88257215d.jpg +0 -0
  898. package/kb/references/thumbs/f88c45a6d7.jpg +0 -0
  899. package/kb/references/thumbs/f89a7b646e.jpg +0 -0
  900. package/kb/references/thumbs/f8a1e7a274.jpg +0 -0
  901. package/kb/references/thumbs/f9183cf2b6.jpg +0 -0
  902. package/kb/references/thumbs/f995de5ce9.jpg +0 -0
  903. package/kb/references/thumbs/f9ada2c3e1.jpg +0 -0
  904. package/kb/references/thumbs/f9d4616f89.jpg +0 -0
  905. package/kb/references/thumbs/f9f4307bb0.jpg +0 -0
  906. package/kb/references/thumbs/fa070122c7.jpg +0 -0
  907. package/kb/references/thumbs/fa29cc1369.jpg +0 -0
  908. package/kb/references/thumbs/fa791a4dab.jpg +0 -0
  909. package/kb/references/thumbs/fa8060509e.jpg +0 -0
  910. package/kb/references/thumbs/fafeed4003.jpg +0 -0
  911. package/kb/references/thumbs/fb1c024f38.jpg +0 -0
  912. package/kb/references/thumbs/fb77aa28b3.jpg +0 -0
  913. package/kb/references/thumbs/fb9f7f1966.jpg +0 -0
  914. package/kb/references/thumbs/fba8a8c2da.jpg +0 -0
  915. package/kb/references/thumbs/fce2754c36.jpg +0 -0
  916. package/kb/references/thumbs/fce2fb83df.jpg +0 -0
  917. package/kb/references/thumbs/fd4c51bb47.jpg +0 -0
  918. package/kb/references/thumbs/fd8c269817.jpg +0 -0
  919. package/kb/references/thumbs/fdb7b250ca.jpg +0 -0
  920. package/kb/references/thumbs/fe18088de8.jpg +0 -0
  921. package/kb/references/thumbs/fe4f742ba7.jpg +0 -0
  922. package/kb/references/thumbs/fe64742ce7.jpg +0 -0
  923. package/kb/references/thumbs/feb5d62161.jpg +0 -0
  924. package/kb/references/thumbs/fed21b3e98.jpg +0 -0
  925. package/kb/references/thumbs/fed2205951.jpg +0 -0
  926. package/kb/references/thumbs/ffdacf1715.jpg +0 -0
  927. package/kb/sections/_buttons.md +93 -0
  928. package/kb/sections/_finish.md +325 -0
  929. package/kb/sections/about-founder-note.md +85 -0
  930. package/kb/sections/about-team-roster.md +86 -0
  931. package/kb/sections/alternating-rows-live-tiles.md +110 -0
  932. package/kb/sections/annotated-product-shot.md +105 -0
  933. package/kb/sections/asymmetric-bento-dominant.md +114 -0
  934. package/kb/sections/before-after-features.md +104 -0
  935. package/kb/sections/before-after-repair.md +107 -0
  936. package/kb/sections/bento-lead-tile.md +112 -0
  937. package/kb/sections/book-a-slot-honest.md +116 -0
  938. package/kb/sections/capability-prompt-ledger.md +68 -0
  939. package/kb/sections/case-study-band.md +113 -0
  940. package/kb/sections/centred-stack-hero.md +107 -0
  941. package/kb/sections/closing-demo-final-state.md +81 -0
  942. package/kb/sections/closing-full-bleed-line-and-form.md +86 -0
  943. package/kb/sections/closing-result-previewed.md +88 -0
  944. package/kb/sections/closing-three-real-steps.md +87 -0
  945. package/kb/sections/command-reference-table.md +70 -0
  946. package/kb/sections/comparison-old-way-bars.md +88 -0
  947. package/kb/sections/comparison-same-task-two-ways.md +88 -0
  948. package/kb/sections/day-of-the-place.md +102 -0
  949. package/kb/sections/docs-reading-rail.md +88 -0
  950. package/kb/sections/editorial-columns-hero.md +107 -0
  951. package/kb/sections/editorial-essay-pull-quote.md +112 -0
  952. package/kb/sections/events-calendar-list.md +107 -0
  953. package/kb/sections/explorable-control-demo.md +102 -0
  954. package/kb/sections/faq-open-answers-editorial.md +85 -0
  955. package/kb/sections/faq-ruled-accordion-aside.md +87 -0
  956. package/kb/sections/footer-closing-scene.md +84 -0
  957. package/kb/sections/footer-editorial-sitemap.md +85 -0
  958. package/kb/sections/footer-minimal-credits.md +80 -0
  959. package/kb/sections/full-bleed-image-band.md +103 -0
  960. package/kb/sections/full-bleed-image-hero.md +105 -0
  961. package/kb/sections/gallery-first-hero.md +109 -0
  962. package/kb/sections/horizontal-scroll-strip.md +101 -0
  963. package/kb/sections/how-i-work-own-voice.md +111 -0
  964. package/kb/sections/how-it-works-in-place.md +109 -0
  965. package/kb/sections/impact-numbers-sourced.md +109 -0
  966. package/kb/sections/install-steps-per-agent.md +77 -0
  967. package/kb/sections/masonry-real-work.md +109 -0
  968. package/kb/sections/numbered-editorial-columns.md +105 -0
  969. package/kb/sections/object-first-hero.md +106 -0
  970. package/kb/sections/object-timeline.md +105 -0
  971. package/kb/sections/one-huge-number.md +100 -0
  972. package/kb/sections/one-person-story.md +103 -0
  973. package/kb/sections/order-ahead-ticket.md +119 -0
  974. package/kb/sections/press-strip-quoted.md +106 -0
  975. package/kb/sections/pricing-comparison-table-sticky.md +94 -0
  976. package/kb/sections/pricing-free-pro-upgrade-moment.md +85 -0
  977. package/kb/sections/pricing-plan-beside-product.md +88 -0
  978. package/kb/sections/pricing-plans-ledger.md +94 -0
  979. package/kb/sections/pricing-usage-calculator.md +93 -0
  980. package/kb/sections/printed-menu-card.md +110 -0
  981. package/kb/sections/process-happening.md +102 -0
  982. package/kb/sections/project-index-ruled.md +106 -0
  983. package/kb/sections/proof-logo-cloud-with-reasons.md +82 -0
  984. package/kb/sections/proof-measured-numbers.md +88 -0
  985. package/kb/sections/proof-output-wall.md +84 -0
  986. package/kb/sections/proof-single-honest-quote.md +81 -0
  987. package/kb/sections/resources-searchable-list.md +110 -0
  988. package/kb/sections/rooms-and-rates.md +107 -0
  989. package/kb/sections/ruled-facts-table.md +108 -0
  990. package/kb/sections/ruled-list-pinned-visual.md +107 -0
  991. package/kb/sections/scroll-scrubbed-product-demo.md +103 -0
  992. package/kb/sections/self-playing-run-demo.md +104 -0
  993. package/kb/sections/services-price-ledger.md +106 -0
  994. package/kb/sections/signup-form-with-next-steps.md +86 -0
  995. package/kb/sections/signup-waitlist-place-in-line.md +90 -0
  996. package/kb/sections/spec-sheet-features.md +108 -0
  997. package/kb/sections/split-hero-live-object.md +108 -0
  998. package/kb/sections/sticky-feature-narrative.md +104 -0
  999. package/kb/sections/todays-board-hero.md +112 -0
  1000. package/kb/sections/touchable-tool-hero.md +111 -0
  1001. package/kb/sections/typographic-statement-hero.md +99 -0
  1002. package/kb/sections/visit-hours-map.md +110 -0
  1003. package/kb/sections/ways-to-help-honest.md +115 -0
  1004. package/kb/sources/design-reference.md +991 -0
  1005. package/kb/sources/product-and-docs.md +37 -0
  1006. package/kb/systems/1win/DESIGN.md +463 -0
  1007. package/kb/systems/LICENSE-awesome-design-md +21 -0
  1008. package/kb/systems/README.md +7 -0
  1009. package/kb/systems/airbnb/DESIGN.md +545 -0
  1010. package/kb/systems/airtable/DESIGN.md +554 -0
  1011. package/kb/systems/aljazeera/DESIGN.md +594 -0
  1012. package/kb/systems/apple/DESIGN.md +562 -0
  1013. package/kb/systems/binance/DESIGN.md +634 -0
  1014. package/kb/systems/bmw/DESIGN.md +544 -0
  1015. package/kb/systems/bmw-m/DESIGN.md +503 -0
  1016. package/kb/systems/bugatti/DESIGN.md +454 -0
  1017. package/kb/systems/cal/DESIGN.md +542 -0
  1018. package/kb/systems/categories.json +87 -0
  1019. package/kb/systems/claude/DESIGN.md +589 -0
  1020. package/kb/systems/clay/DESIGN.md +541 -0
  1021. package/kb/systems/clickhouse/DESIGN.md +544 -0
  1022. package/kb/systems/cohere/DESIGN.md +451 -0
  1023. package/kb/systems/coinbase/DESIGN.md +570 -0
  1024. package/kb/systems/composio/DESIGN.md +506 -0
  1025. package/kb/systems/cursor/DESIGN.md +537 -0
  1026. package/kb/systems/dell-1996/DESIGN.md +632 -0
  1027. package/kb/systems/dji/DESIGN.md +882 -0
  1028. package/kb/systems/elevenlabs/DESIGN.md +504 -0
  1029. package/kb/systems/expo/DESIGN.md +526 -0
  1030. package/kb/systems/ferrari/DESIGN.md +531 -0
  1031. package/kb/systems/figma/DESIGN.md +578 -0
  1032. package/kb/systems/framer/DESIGN.md +544 -0
  1033. package/kb/systems/hashicorp/DESIGN.md +575 -0
  1034. package/kb/systems/hp/DESIGN.md +670 -0
  1035. package/kb/systems/ibm/DESIGN.md +550 -0
  1036. package/kb/systems/intercom/DESIGN.md +546 -0
  1037. package/kb/systems/jobright/DESIGN.md +525 -0
  1038. package/kb/systems/kraken/DESIGN.md +125 -0
  1039. package/kb/systems/lamborghini/DESIGN.md +288 -0
  1040. package/kb/systems/linear.app/DESIGN.md +548 -0
  1041. package/kb/systems/lovable/DESIGN.md +298 -0
  1042. package/kb/systems/mastercard/DESIGN.md +365 -0
  1043. package/kb/systems/mercury/DESIGN.md +568 -0
  1044. package/kb/systems/meta/DESIGN.md +683 -0
  1045. package/kb/systems/minimax/DESIGN.md +746 -0
  1046. package/kb/systems/mintlify/DESIGN.md +852 -0
  1047. package/kb/systems/miro/DESIGN.md +825 -0
  1048. package/kb/systems/mistral.ai/DESIGN.md +773 -0
  1049. package/kb/systems/mongodb/DESIGN.md +767 -0
  1050. package/kb/systems/mydealz/DESIGN.md +551 -0
  1051. package/kb/systems/nerdwallet/DESIGN.md +722 -0
  1052. package/kb/systems/nike/DESIGN.md +575 -0
  1053. package/kb/systems/nintendo-2001/DESIGN.md +649 -0
  1054. package/kb/systems/notion/DESIGN.md +821 -0
  1055. package/kb/systems/nvidia/DESIGN.md +640 -0
  1056. package/kb/systems/ollama/DESIGN.md +539 -0
  1057. package/kb/systems/opencode.ai/DESIGN.md +521 -0
  1058. package/kb/systems/oracle/DESIGN.md +778 -0
  1059. package/kb/systems/oura/DESIGN.md +616 -0
  1060. package/kb/systems/pinterest/DESIGN.md +597 -0
  1061. package/kb/systems/playstation/DESIGN.md +661 -0
  1062. package/kb/systems/posthog/DESIGN.md +690 -0
  1063. package/kb/systems/raycast/DESIGN.md +669 -0
  1064. package/kb/systems/renault/DESIGN.md +589 -0
  1065. package/kb/systems/replicate/DESIGN.md +616 -0
  1066. package/kb/systems/resend/DESIGN.md +585 -0
  1067. package/kb/systems/revolut/DESIGN.md +636 -0
  1068. package/kb/systems/runwayml/DESIGN.md +244 -0
  1069. package/kb/systems/sanity/DESIGN.md +357 -0
  1070. package/kb/systems/sap/DESIGN.md +639 -0
  1071. package/kb/systems/sentry/DESIGN.md +551 -0
  1072. package/kb/systems/shopify/DESIGN.md +516 -0
  1073. package/kb/systems/slack/DESIGN.md +482 -0
  1074. package/kb/systems/spacex/DESIGN.md +363 -0
  1075. package/kb/systems/spotify/DESIGN.md +246 -0
  1076. package/kb/systems/starbucks/DESIGN.md +580 -0
  1077. package/kb/systems/stripe/DESIGN.md +487 -0
  1078. package/kb/systems/supabase/DESIGN.md +462 -0
  1079. package/kb/systems/superhuman/DESIGN.md +448 -0
  1080. package/kb/systems/tesla/DESIGN.md +286 -0
  1081. package/kb/systems/theverge/DESIGN.md +339 -0
  1082. package/kb/systems/together.ai/DESIGN.md +633 -0
  1083. package/kb/systems/uber/DESIGN.md +636 -0
  1084. package/kb/systems/vercel/DESIGN.md +736 -0
  1085. package/kb/systems/vodafone/DESIGN.md +538 -0
  1086. package/kb/systems/voltagent/DESIGN.md +521 -0
  1087. package/kb/systems/warp/DESIGN.md +526 -0
  1088. package/kb/systems/webflow/DESIGN.md +588 -0
  1089. package/kb/systems/wired/DESIGN.md +497 -0
  1090. package/kb/systems/wise/DESIGN.md +544 -0
  1091. package/kb/systems/x.ai/DESIGN.md +465 -0
  1092. package/kb/systems/zapier/DESIGN.md +537 -0
  1093. package/kb/taste/garvit-by-job.md +46 -0
  1094. package/kb/taste/garvit-portfolio.md +94 -0
  1095. package/kb/universal/color-roles.md +26 -0
  1096. package/kb/universal/copy.md +16 -0
  1097. package/kb/universal/details.md +31 -0
  1098. package/kb/universal/first-screen.md +30 -0
  1099. package/kb/universal/fit-the-codebase.md +29 -0
  1100. package/kb/universal/layout-width.md +28 -0
  1101. package/kb/universal/motion-feedback.md +23 -0
  1102. package/kb/universal/signature.md +29 -0
  1103. package/kb/universal/states.md +17 -0
  1104. package/kb/universal/type-app.md +33 -0
  1105. package/kb/universal/type-marketing.md +28 -0
  1106. package/package.json +37 -4
  1107. package/scripts/pack-assets.mjs +58 -0
  1108. package/skill/palate/SKILL.md +261 -0
  1109. package/src/auth-recheck.mjs +4 -0
  1110. package/src/auth.mjs +416 -0
  1111. package/src/capture.mjs +182 -0
  1112. package/src/catalog/classify.mjs +96 -0
  1113. package/src/catalog/curated.mjs +128 -0
  1114. package/src/catalog/index-build.mjs +67 -0
  1115. package/src/catalog/literal.mjs +146 -0
  1116. package/src/catalog/store.mjs +40 -0
  1117. package/src/catalog/vibrant.mjs +126 -0
  1118. package/src/check.mjs +93 -0
  1119. package/src/concept.mjs +157 -0
  1120. package/src/context.mjs +226 -0
  1121. package/src/copy.mjs +188 -0
  1122. package/src/devserver.mjs +135 -0
  1123. package/src/fontgenre.mjs +44 -0
  1124. package/src/guard-agents.mjs +213 -0
  1125. package/src/guard.mjs +377 -0
  1126. package/src/kb.mjs +91 -0
  1127. package/src/look.mjs +312 -0
  1128. package/src/loop/ambition.mjs +620 -0
  1129. package/src/loop/animate.mjs +70 -0
  1130. package/src/loop/artdirection.mjs +264 -0
  1131. package/src/loop/bench.mjs +184 -0
  1132. package/src/loop/benchpick.mjs +155 -0
  1133. package/src/loop/blueprint.mjs +620 -0
  1134. package/src/loop/board.html +180 -0
  1135. package/src/loop/build.mjs +393 -0
  1136. package/src/loop/buttons.mjs +413 -0
  1137. package/src/loop/common.mjs +206 -0
  1138. package/src/loop/cost.mjs +63 -0
  1139. package/src/loop/craft.mjs +152 -0
  1140. package/src/loop/craftchecks.mjs +148 -0
  1141. package/src/loop/critique.mjs +393 -0
  1142. package/src/loop/details.mjs +225 -0
  1143. package/src/loop/direction.html +168 -0
  1144. package/src/loop/direction.mjs +256 -0
  1145. package/src/loop/directions.json +8 -0
  1146. package/src/loop/explore.mjs +219 -0
  1147. package/src/loop/fail.mjs +110 -0
  1148. package/src/loop/fix.mjs +194 -0
  1149. package/src/loop/floor.mjs +677 -0
  1150. package/src/loop/forget.mjs +52 -0
  1151. package/src/loop/gate.mjs +1053 -0
  1152. package/src/loop/grid.html +123 -0
  1153. package/src/loop/growth.mjs +118 -0
  1154. package/src/loop/icons.mjs +109 -0
  1155. package/src/loop/imagery.mjs +1280 -0
  1156. package/src/loop/judge-api.mjs +245 -0
  1157. package/src/loop/judge.mjs +167 -0
  1158. package/src/loop/layouts.mjs +492 -0
  1159. package/src/loop/library.mjs +82 -0
  1160. package/src/loop/lookplan.mjs +182 -0
  1161. package/src/loop/lookvec.mjs +645 -0
  1162. package/src/loop/match.mjs +923 -0
  1163. package/src/loop/matched.mjs +26 -0
  1164. package/src/loop/myrefs.mjs +151 -0
  1165. package/src/loop/pick.mjs +213 -0
  1166. package/src/loop/picker.html +118 -0
  1167. package/src/loop/plan.mjs +289 -0
  1168. package/src/loop/polish.mjs +39 -0
  1169. package/src/loop/research-learn.mjs +41 -0
  1170. package/src/loop/research.mjs +1130 -0
  1171. package/src/loop/rulereview.mjs +146 -0
  1172. package/src/loop/rules-board.html +199 -0
  1173. package/src/loop/rules.mjs +939 -0
  1174. package/src/loop/similar.mjs +276 -0
  1175. package/src/loop/states-source.mjs +258 -0
  1176. package/src/loop/states.mjs +1547 -0
  1177. package/src/loop/stats.mjs +78 -0
  1178. package/src/loop/taste.mjs +81 -0
  1179. package/src/loop/timeline.mjs +149 -0
  1180. package/src/loop/variants.mjs +228 -0
  1181. package/src/loop/vary.mjs +249 -0
  1182. package/src/mcp/server.mjs +1246 -0
  1183. package/src/motion.mjs +207 -0
  1184. package/src/moves.mjs +242 -0
  1185. package/src/novelty.mjs +116 -0
  1186. package/src/page-ambition.js +181 -0
  1187. package/src/page-craft.js +724 -0
  1188. package/src/page-details.js +415 -0
  1189. package/src/page-devices.js +365 -0
  1190. package/src/page-fingerprint.js +281 -0
  1191. package/src/page-gate.js +1307 -0
  1192. package/src/page-look.js +698 -0
  1193. package/src/page-metrics.js +78 -0
  1194. package/src/page-principles.js +1167 -0
  1195. package/src/page-rules.js +308 -0
  1196. package/src/page-sections.js +211 -0
  1197. package/src/page-states.js +703 -0
  1198. package/src/paths.mjs +13 -0
  1199. package/src/png.mjs +107 -0
  1200. package/src/point/auto.mjs +224 -0
  1201. package/src/point/locate.mjs +114 -0
  1202. package/src/point/overlay.js +1516 -0
  1203. package/src/point/server.mjs +1122 -0
  1204. package/src/point/trail.mjs +219 -0
  1205. package/src/pool.mjs +80 -0
  1206. package/src/principles.mjs +279 -0
  1207. package/src/refs.mjs +299 -0
  1208. package/src/robots.mjs +145 -0
  1209. package/src/sections.mjs +210 -0
  1210. package/src/shoot.mjs +235 -0
  1211. package/src/systems/apply.mjs +355 -0
  1212. package/src/systems/load.mjs +251 -0
  1213. package/src/systems/pick.mjs +213 -0
  1214. package/templates/vite-react-tw/index.html +12 -0
  1215. package/templates/vite-react-tw/package.json +25 -0
  1216. package/templates/vite-react-tw/src/App.tsx +3 -0
  1217. package/templates/vite-react-tw/src/index.css +1 -0
  1218. package/templates/vite-react-tw/src/main.tsx +10 -0
  1219. package/templates/vite-react-tw/tsconfig.app.json +26 -0
  1220. package/templates/vite-react-tw/tsconfig.json +7 -0
  1221. package/templates/vite-react-tw/tsconfig.node.json +23 -0
  1222. package/templates/vite-react-tw/vite.config.ts +8 -0
@@ -0,0 +1,1547 @@
1
+ // `palate states <route>`: the last 20%. Finds the stateful parts of a screen (lists, tables, card grids, forms,
2
+ // numbers, images, text that comes from data, buttons that act) from the DOM and a quick read of the source,
3
+ // renders the screen in every state that matters without touching the user's code, measures each one, and
4
+ // writes a contact sheet (one row per part, one column per state), findings.json and a brief of the missing
5
+ // states, most important first.
6
+ // States with data fetched in the browser: Playwright route mocks rewrite the app's own JSON (empty, one
7
+ // item, 50 items, long strings), hold it (loading, no answer), or fail it (500, network down).
8
+ // Data written in the code (a static page): DOM-level transforms (long text, many items, one item); the
9
+ // rest is reported as not simulable, never pretended.
10
+ // Always: images that fail, 320 wide, 200% zoom with a keyboard-only pass, and forms and buttons pressed
11
+ // twice, failing, offline and empty, with every request they send answered by palate (never the app's
12
+ // server, so nothing is written).
13
+ // Every form also fails a submit by keyboard only (at 1440 and 320, long values): focus, message, announcement,
14
+ // values kept, on screen, and fixed by keyboard.
15
+ // `palate states <route> --verify` runs it again and puts each earlier finding before | after.
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { contactSheet, HIDE_DEV, scrollThrough, slugOf, stamp, withBrowser } from "../look.mjs";
20
+ import { normalizeRoute } from "../shoot.mjs";
21
+ import { readJson } from "./common.mjs";
22
+ import { slash } from "./craft.mjs";
23
+ import { checkRules, projectOf, routeTags, rulesBlock, STATE_METRICS } from "./rules.mjs";
24
+ import { dataKind, scanSource, whereIs } from "./states-source.mjs";
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const script = fs.readFileSync(path.join(here, "..", "page-states.js"), "utf8").trim().replace(/;\s*$/, "");
28
+ const inPage = (page, op, arg = {}) => page.evaluate(`(${script})(${JSON.stringify(op)}, ${JSON.stringify(arg)})`);
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
31
+ // Requests that carry the app's data (fetch / XHR), not the dev server's own traffic or assets.
32
+ const NOISE = /\/_next\/|__nextjs|webpack-hmr|turbopack|hot-update|\/@vite\/|\/@fs\/|\/@id\/|\/@react-refresh|\/node_modules\/|__vite|\/__palate\/|[?&]_rsc=|sockjs|\/favicon|\.(m?js|jsx|tsx?|css|map|woff2?|ttf|otf|png|jpe?g|gif|svg|webp|avif|ico)(\?|$)/i;
33
+ export const isData = (req) => ["fetch", "xhr"].includes(req.resourceType()) && !NOISE.test(req.url());
34
+ const isRead = (req) => ["GET", "HEAD"].includes(req.method());
35
+ const okJson = { status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) };
36
+ const LOAD_HOLD = 5000;
37
+ // 500 rows in a dev build (React dev mode renders 2-5x slower than production): past 2s a list needs virtualizing.
38
+ const SLOW_500 = 2000;
39
+
40
+ export const STATES = {
41
+ default: "as it is",
42
+ empty: "empty",
43
+ loading: "loading",
44
+ error: "error 500",
45
+ network: "network down",
46
+ hang: "no answer",
47
+ long: "long text",
48
+ many: "50 items",
49
+ one: "one item",
50
+ images: "images fail",
51
+ narrow: "320 wide",
52
+ submitting: "submitting (pressed twice)",
53
+ "submit-error": "submit fails",
54
+ offline: "offline",
55
+ "empty-submit": "empty submit",
56
+ "lost-retry": "answer lost, retried",
57
+ recover: "keyboard error recovery at 1440",
58
+ "recover-narrow": "keyboard error recovery at 320",
59
+ zoom: "200% + keyboard",
60
+ pressed: "pressed twice",
61
+ };
62
+
63
+ // ---- the app's JSON, rewritten for a state
64
+ const COUNT_KEY = /^(total|count|totalCount|total_count|length|size|totalItems|total_items|totalResults)$/;
65
+ const KEEP_KEY = /(^|_)(id|uuid|url|src|href|avatar|image|img|photo|picture|icon|logo|slug|key|date|time|created|updated|at|status|type|role|color|colour|token|locale|lang|currency)$|(Id|Url|At|Src)$/i;
66
+ const LONG = {
67
+ name: "Maximiliana Alexandrova-Featherstonehaugh de la Cruz-Villanueva",
68
+ company: "International Consolidated Holdings & Subsidiaries Worldwide Ltd.",
69
+ email: "maximiliana.alexandrova-featherstonehaugh@international-consolidated-holdings.co.uk",
70
+ word: "Supercalifragilisticexpialidocious-pneumonoultramicroscopicsilicovolcanoconiosis",
71
+ };
72
+ export function shapeData(body, mode, n = 50) {
73
+ let k = 0;
74
+ const longer = (v, key) => {
75
+ if (/^\S+@\S+\.\S+$/.test(v) || /e-?mail/i.test(key)) return LONG.email;
76
+ if (/^(https?:|\/|data:)|^\d{4}-\d\d-\d\d|^#[0-9a-f]{3,8}$/i.test(v)) return v;
77
+ if (/company|org|team|workspace|account|title/i.test(key)) return k++ ? LONG.company : `${LONG.company} ${LONG.word}`;
78
+ if (/name|author|user|owner|label/i.test(key)) return k++ ? LONG.name : `${LONG.name} ${LONG.word}`;
79
+ return v.length < 80 ? `${v} ${v} ${v}` : v;
80
+ };
81
+ const hasArray = (o) => Object.values(o).some(Array.isArray);
82
+ const uniq = (v, i) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).map(([key, x]) => [key, /^(id|uuid|key|slug)$|Id$/.test(key) && i ? (typeof x === "number" ? x + i * 100_000 : `${x}-${i}`) : x])) : v);
83
+ const rec = (v, key, depth) => {
84
+ if (Array.isArray(v)) {
85
+ if (mode === "empty") return [];
86
+ if (mode === "one") return v.length ? [rec(v[0], key, depth + 1)] : v;
87
+ if (mode === "many") return v.length ? Array.from({ length: n }, (_, i) => uniq(rec(v[i % v.length], key, depth + 1), Math.floor(i / v.length) ? i : 0)) : v;
88
+ return v.map((x) => rec(x, key, depth + 1));
89
+ }
90
+ if (v && typeof v === "object") {
91
+ if (mode === "empty" && depth === 0 && !hasArray(v)) return {};
92
+ return Object.fromEntries(Object.entries(v).map(([key2, x]) => [key2, COUNT_KEY.test(key2) && typeof x === "number" ? (mode === "empty" ? 0 : mode === "one" ? 1 : mode === "many" ? n : x) : rec(x, key2, depth + 1)]));
93
+ }
94
+ if (mode === "long" && typeof v === "string" && v && !KEEP_KEY.test(key)) return longer(v, key);
95
+ return v;
96
+ };
97
+ return rec(body, "", 0);
98
+ }
99
+ const jsonMock = (fn) => async (r, req) => {
100
+ if (!isRead(req)) return r.fulfill(okJson);
101
+ let res;
102
+ try {
103
+ res = await r.fetch();
104
+ } catch {
105
+ return r.abort();
106
+ }
107
+ if (!/json/.test(res.headers()["content-type"] ?? "")) return r.fulfill({ response: res });
108
+ let body;
109
+ try {
110
+ body = await res.json();
111
+ } catch {
112
+ return r.fulfill({ response: res });
113
+ }
114
+ return r.fulfill({ response: res, json: fn(body) });
115
+ };
116
+ const stringsOf = (v, out = new Set()) => {
117
+ if (out.size > 400) return out;
118
+ if (typeof v === "string" && v.length >= 3 && v.length <= 80 && !/^(https?:|\/)/.test(v)) out.add(v);
119
+ else if (Array.isArray(v)) v.forEach((x) => stringsOf(x, out));
120
+ else if (v && typeof v === "object") Object.values(v).forEach((x) => stringsOf(x, out));
121
+ return out;
122
+ };
123
+
124
+ // ---- one render of the route under a state
125
+ async function visit(browser, o = {}) {
126
+ const ctx = await browser.newContext({ viewport: { width: o.width ?? 1440, height: o.height ?? 900 }, deviceScaleFactor: o.scale ?? 1, reducedMotion: o.motion ? "no-preference" : "reduce" });
127
+ await ctx.addInitScript(HIDE_DEV);
128
+ const log = { data: [], errors: [], held: [] };
129
+ let first;
130
+ log.first = new Promise((r) => (first = r));
131
+ await ctx.route("**/*", async (r) => {
132
+ const req = r.request();
133
+ try {
134
+ if (o.images && req.resourceType() === "image") return await r.fulfill({ status: 404, body: "" });
135
+ if (!isData(req)) return await r.fallback();
136
+ const rec = { method: req.method(), url: req.url(), at: Date.now() };
137
+ log.data.push(rec);
138
+ first(rec);
139
+ if (o.data) return await o.data(r, req, log);
140
+ // Nothing palate does may write to the app's server: every send is answered here.
141
+ if (!isRead(req)) return await r.fulfill(okJson);
142
+ return await r.fallback();
143
+ } catch {
144
+ // The page closed while the request was held.
145
+ }
146
+ });
147
+ const page = await ctx.newPage();
148
+ page.on("pageerror", (e) => log.errors.push(String(e.message).slice(0, 200)));
149
+ const close = async () => {
150
+ log.held.splice(0).forEach((h) => h());
151
+ await ctx.close().catch(() => {});
152
+ };
153
+ return { ctx, page, log, close };
154
+ }
155
+ async function go(page, url, { wait = "load" } = {}) {
156
+ for (let k = 0; k < 2; k++) {
157
+ try {
158
+ await page.goto(url, { waitUntil: wait, timeout: 60_000 });
159
+ return true;
160
+ } catch (e) {
161
+ if (k) return false;
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+ const settle = async (page, idle = 8000) => {
167
+ await page.waitForLoadState("networkidle", { timeout: idle }).catch(() => {});
168
+ await page.waitForTimeout(500);
169
+ };
170
+
171
+ // Where to crop a part in a state: its own box when it is there (a short form or a number is still its own
172
+ // box, never its neighbours); a list that emptied to a line or vanished is cropped where it was, at its
173
+ // original size, so what replaced it shows.
174
+ export const placeOf = (mc, base, c = null) => {
175
+ const b = mc?.box;
176
+ const list = !c || Boolean(c.sig);
177
+ if (b && b.height >= (list ? 40 : 8)) return b;
178
+ if (!base) return b ?? mc?.regionBox ?? null;
179
+ return { x: base.x, y: b?.y ?? base.y, width: base.width, height: list ? Math.max(base.height, 80) : base.height };
180
+ };
181
+
182
+ async function crop(page, b, file, { pad = 12, maxH = 560 } = {}) {
183
+ if (!b || b.width < 2 || b.height < 2) return null;
184
+ const { w, h } = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight })).catch(() => ({ w: 0, h: 0 }));
185
+ const x = Math.max(0, b.x - pad);
186
+ const y = Math.max(0, b.y - pad);
187
+ const clip = { x, y, width: Math.min(w - x, b.width + pad * 2), height: Math.min(h - y, b.height + pad * 2, maxH) };
188
+ if (clip.width < 8 || clip.height < 8) return null;
189
+ await page.screenshot({ path: file, fullPage: true, clip }).catch(() => null);
190
+ return fs.existsSync(file) ? file : null;
191
+ }
192
+
193
+ // ---- test values: what palate types into a field so the form's own validation lets it through. Several per
194
+ // field, most likely first; each one kept only if it meets the field's pattern, length and range. A phone
195
+ // placeholder ("07700 900123") is the best guess at the format the form wants, so it goes first.
196
+ const PHONES = ["07700 900123", "+44 7700 900123", "(555) 010-0123", "+1 555 010 0123", "5550100123", "0412 345 678", "+61 412 345 678", "030 901820", "+49 30 901820", "01 23 45 67 89", "+33 1 23 45 67 89", "98765 43210", "+91 98765 43210"];
197
+ const POSTCODES = ["SW1A 1AA", "10001", "94103", "75001", "10115", "2000", "M5V 2T6"];
198
+ const day = (n) => new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);
199
+ export function testValues(x) {
200
+ const r = x.rules ?? {};
201
+ const k = `${x.name} ${x.label} ${r.autocomplete ?? ""}`;
202
+ const ph = (r.placeholder ?? "").replace(/^(e\.?g\.?|for example|like|such as)[:,]?\s*/i, "").trim();
203
+ let list;
204
+ if (x.type === "email" || /e-?mail/i.test(k)) list = ["test@example.com", "palate.test@example.org"];
205
+ else if (x.type === "url" || /\burl\b|website/i.test(k)) list = ["https://example.com", "example.com"];
206
+ else if (x.type === "number" || x.type === "range") {
207
+ const lo = Number.isFinite(parseFloat(r.min)) ? parseFloat(r.min) : null;
208
+ const hi = Number.isFinite(parseFloat(r.max)) ? parseFloat(r.max) : null;
209
+ list = [String(lo !== null && hi !== null ? Math.round((lo + hi) / 2) : lo !== null ? lo + 1 : hi !== null ? Math.min(3, hi) : 3)];
210
+ } else if (x.type === "tel" || /phone|mobile|\btel\b/i.test(k) || r.inputMode === "tel") list = [/\d{3}/.test(ph) ? ph : null, ...PHONES];
211
+ else if (/postal|post ?code|\bzip\b/i.test(k)) list = [/\d/.test(ph) ? ph : null, ...POSTCODES];
212
+ else if (x.type === "password") list = ["palate-Test-2026!", "Palate-test-2026"];
213
+ else if (x.type === "date") list = [day(60), day(14), day(180)].filter((d) => (!r.min || d >= r.min) && (!r.max || d <= r.max)).concat(r.min && /^\d{4}-\d\d-\d\d$/.test(r.min) ? [r.min] : []);
214
+ else if (x.type === "time") list = ["10:30", "14:00"];
215
+ else if (/numeric|decimal/.test(r.inputMode ?? "")) list = [/^\d[\d\s]*$/.test(ph) ? ph : null, "123456", "1234", "12"];
216
+ else if (x.type === "textarea") list = ["A short test message from palate, to check how the form sends."];
217
+ else if (/name/i.test(k)) list = ["Test User", "Sam Taylor"];
218
+ else list = [ph && ph.length <= 40 && !/^(your|enter|type|search|add)\b/i.test(ph) ? ph : null, "Test", "Test value"];
219
+ list = [...new Set(list.filter(Boolean))];
220
+ let re = null;
221
+ try {
222
+ re = r.pattern ? new RegExp(`^(?:${r.pattern})$`, "v") : null;
223
+ } catch {}
224
+ const fits = (v) => (!re || re.test(v)) && (!r.minLength || v.length >= r.minLength) && (!r.maxLength || v.length <= r.maxLength);
225
+ const ok = list.filter(fits);
226
+ if (ok.length) return ok;
227
+ // Nothing fits: stretch the first to the minimum length, or digits only (a pattern of digits).
228
+ const pad = (v) => (r.minLength && v.length < r.minLength ? `${v} ${"x".repeat(r.minLength)}`.slice(0, Math.max(r.minLength, v.length)) : v);
229
+ return [...new Set([...list.map(pad), ...list.map((v) => v.replace(/\D/g, "")).filter(Boolean)])].filter(fits).concat(list).slice(0, 6);
230
+ }
231
+
232
+ async function fill(page, comp, values = comp.values) {
233
+ for (const [i, f] of comp.fields.entries()) {
234
+ const loc = page.locator(f.sel).first();
235
+ const v = values?.[i] ?? testValues(f)[0] ?? "Test";
236
+ try {
237
+ if (f.type === "select") await loc.selectOption({ index: 1 }, { timeout: 1500 });
238
+ else if (f.type === "checkbox" || f.type === "radio") {
239
+ if (f.required || values?.[i] === true) await loc.check({ timeout: 1500 });
240
+ } else if (!["file", "color", "hidden"].includes(f.type)) await loc.fill(String(v), { timeout: 1500 });
241
+ } catch {}
242
+ }
243
+ }
244
+
245
+ // Fills the form and sends it until its own validation lets it through: each field the page marks wrong (or
246
+ // names in its message) moves on to its next test value, up to five rounds. Returns the values that got
247
+ // through, or what was tried and the message that stopped it. Writes are answered by palate.
248
+ export async function probeValues(page, f, { writes = () => 0, baseTexts = [] } = {}) {
249
+ const lists = f.fields.map((x) => (["checkbox", "radio", "select", "file", "color", "hidden"].includes(x.type) ? [null] : testValues(x)));
250
+ const at = f.fields.map(() => 0);
251
+ const values = () => f.fields.map((x, i) => (x.type === "checkbox" || x.type === "radio" ? at[i] > 0 || null : lists[i][at[i]] ?? null));
252
+ const arg = { id: f.id, fields: f.fields.map((x) => x.sel), baseTexts };
253
+ const tried = [];
254
+ let last = null;
255
+ for (let round = 0; round < 5; round++) {
256
+ await fill(page, f, values());
257
+ const sent0 = writes();
258
+ await page.locator(f.submit).first().click({ timeout: 3000 }).catch(() => {});
259
+ await page.waitForTimeout(800);
260
+ last = await inPage(page, "validity", arg).catch(() => null);
261
+ tried.push(values());
262
+ if (writes() > sent0) return { passed: true, sent: true, values: values(), rounds: round + 1, tried };
263
+ if (!last) break;
264
+ // Which fields to change: the ones marked, else the ones the message names, else every typed one.
265
+ const words = (x) => `${x.name} ${x.label}`.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4);
266
+ const named = (t) => f.fields.map((x, i) => (t && words(x).some((w) => t.toLowerCase().includes(w)) ? i : -1)).filter((i) => i >= 0);
267
+ let bad = last.fields.map((x, i) => (x?.invalid || x?.said ? i : -1)).filter((i) => i >= 0);
268
+ if (!bad.length && last.message) bad = named(last.message);
269
+ if (!bad.length && !last.message) return { passed: true, sent: false, values: values(), rounds: round + 1, tried };
270
+ if (!bad.length) bad = f.fields.map((x, i) => i).filter((i) => lists[i].length > 1 || ["checkbox", "radio"].includes(f.fields[i].type));
271
+ let moved = false;
272
+ for (const i of bad) {
273
+ if (["checkbox", "radio"].includes(f.fields[i].type) ? at[i] === 0 : at[i] + 1 < lists[i].length) {
274
+ at[i]++;
275
+ moved = true;
276
+ }
277
+ }
278
+ if (!moved) break;
279
+ }
280
+ const blocked = (last?.fields ?? []).map((x, i) => (x?.invalid || x?.said ? i : -1)).filter((i) => i >= 0);
281
+ return { passed: false, sent: false, values: values(), rounds: tried.length, tried, message: last?.message ?? last?.fields?.find((x) => x?.said)?.said ?? null, blocked: blocked.map((i) => ({ label: fieldName(f.fields[i]), tried: [...new Set(tried.map((v) => v[i]).filter((v) => typeof v === "string"))] })) };
282
+ }
283
+
284
+ // ---- keyboard error recovery: a failed submit, by keyboard only, and what the user can do about it
285
+ // The field left invalid: an email or URL typed malformed (long, realistic), else a required field left empty.
286
+ const NOT_TYPED = new Set(["select", "checkbox", "radio", "file", "color", "hidden", "range", "date", "datetime-local", "time", "month", "week", "submit", "button", "reset", "image"]);
287
+ const BAD = { email: "maximiliana.alexandrova-featherstonehaugh.international-consolidated-holdings.co.uk", url: "international consolidated holdings" };
288
+ export function recoveryPlan(f) {
289
+ const fl = f.fields ?? [];
290
+ const typed = (x) => !NOT_TYPED.has(x.type);
291
+ const mail = (x) => x.type === "email" || (typed(x) && /e-?mail/i.test(`${x.name} ${x.label}`));
292
+ let i = fl.findIndex(mail);
293
+ if (i >= 0) return { target: i, how: "malformed", bad: BAD.email };
294
+ i = fl.findIndex((x) => x.type === "url");
295
+ if (i >= 0) return { target: i, how: "malformed", bad: BAD.url };
296
+ i = fl.findIndex((x) => x.required && typed(x));
297
+ if (i < 0) i = fl.findIndex((x) => x.required);
298
+ if (i < 0) i = fl.findIndex(typed);
299
+ return i < 0 ? null : { target: i, how: "empty", bad: "" };
300
+ }
301
+ export function longValue(x) {
302
+ const k = `${x.name} ${x.label}`;
303
+ if (x.type === "email" || /e-?mail/i.test(k)) return LONG.email;
304
+ if (x.type === "url" || /\burl\b|website|link/i.test(k)) return "https://international-consolidated-holdings.co.uk/studios/ceramics/members";
305
+ if (x.type === "tel" || /phone|\btel\b|mobile/i.test(k)) return "+44 20 7946 0958 ext. 1234";
306
+ if (x.type === "number") return "12";
307
+ if (x.type === "password") return "correct-horse-battery-staple-2026";
308
+ if (/company|organi[sz]ation|\borg\b|team|studio|workspace|business/i.test(k)) return LONG.company;
309
+ if (x.type === "textarea" || /message|note|comment|description|\bbio\b|about|details/i.test(k)) return `${LONG.name} from ${LONG.company} would like to join the Thursday wheel-throwing class and fire twelve pieces in the next bisque.`;
310
+ return LONG.name;
311
+ }
312
+ // A field filled the way a keyboard user does it: select all, delete, type; Space for a checkbox, an arrow for a select.
313
+ async function typeInto(page, x, value, { fix = false } = {}) {
314
+ if (x.type === "checkbox" || x.type === "radio") {
315
+ if (x.required || fix) await page.keyboard.press("Space");
316
+ return;
317
+ }
318
+ if (x.type === "select") {
319
+ if (value !== "") await page.keyboard.press("ArrowDown");
320
+ return;
321
+ }
322
+ if (NOT_TYPED.has(x.type)) return;
323
+ await page.keyboard.press("ControlOrMeta+A");
324
+ await page.keyboard.press("Backspace");
325
+ if (value) await page.keyboard.type(value);
326
+ }
327
+
328
+ // One form, keyboard only: Tab in from the top of the page, fill what the Tab order reaches (long values, one
329
+ // field left invalid), press Enter on the submit, read what happened; then go back to the field by keyboard,
330
+ // fix it, submit again and read again. writes() counts the writes sent so far; shot() crops the failed state.
331
+ export async function keyboardRecovery(page, f, { writes = () => 0, shot = null } = {}) {
332
+ const plan = recoveryPlan(f);
333
+ if (!plan) return { skipped: "it has no field palate could leave invalid" };
334
+ const arg = { id: f.id, fields: f.fields.map((x) => x.sel), submit: f.submit };
335
+ const where = () => inPage(page, "active", arg).catch(() => ({ body: true, field: -1, submit: false, inForm: false }));
336
+ const press = async (k) => {
337
+ await page.keyboard.press(k);
338
+ await page.waitForTimeout(40);
339
+ return where();
340
+ };
341
+ // Tab in from the top.
342
+ await page.evaluate(() => {
343
+ document.activeElement?.blur?.();
344
+ window.scrollTo(0, 0);
345
+ }).catch(() => {});
346
+ let a = { field: -1 };
347
+ let tabs = 0;
348
+ while (tabs < 120 && !(a.field >= 0 || a.submit)) (a = await press("Tab")), tabs++;
349
+ const byScript = !(a.field >= 0 || a.submit);
350
+ if (byScript) {
351
+ await page.locator(arg.fields[0]).first().focus({ timeout: 1500 }).catch(() => {});
352
+ a = await where();
353
+ }
354
+ // Fill what the Tab order reaches, until the submit (or out of the form).
355
+ const typed = new Set();
356
+ for (let k = 0; k < 40 && !a.submit; k++) {
357
+ if (a.field >= 0 && !typed.has(a.field)) {
358
+ typed.add(a.field);
359
+ await typeInto(page, f.fields[a.field], a.field === plan.target ? plan.bad : longFor(f, a.field));
360
+ } else if (!a.inForm && typed.size) break;
361
+ a = await press("Tab");
362
+ }
363
+ // Submit: Enter on the submit button; when the Tab order never got there, Enter in the last field typed.
364
+ const submitBy = async () => {
365
+ if (a.submit) {
366
+ await page.keyboard.press("Enter");
367
+ return "Enter on the submit button";
368
+ }
369
+ for (let k = 0; k < 12 && !(a.field >= 0); k++) a = await press("Shift+Tab");
370
+ await page.keyboard.press("Enter");
371
+ return "Enter in a field (the Tab order never reached the submit button)";
372
+ };
373
+ const armed = await inPage(page, "recover-arm", arg);
374
+ const sent0 = writes();
375
+ const how = await submitBy();
376
+ await page.waitForTimeout(900);
377
+ const r1 = await inPage(page, "recover-read", arg);
378
+ const sent1 = writes() - sent0;
379
+ const out = { plan: { ...plan, label: fieldName(f.fields[plan.target]) }, tabs, byScript, typed: [...typed], how, values: armed.values, r1, sent1 };
380
+ const anyError = r1.nativeBlocked || r1.fields.some((x) => x?.ariaInvalid) || Boolean(r1.message && /\S/.test(r1.message.text) && !/\b(sent|saved|added|thanks|thank you|success)/i.test(r1.message.text));
381
+ if (sent1 > 0 && !anyError) return { ...out, accepted: true };
382
+ if (shot) await shot(r1).catch(() => {});
383
+ // Fix it by keyboard: back to the field (Shift+Tab, then Tab), a valid value, the submit again.
384
+ a = await where();
385
+ for (let k = 0; k < 30 && a.field !== plan.target; k++) a = await press("Shift+Tab");
386
+ for (let k = 0; k < 60 && a.field !== plan.target; k++) a = await press("Tab");
387
+ const fixByScript = a.field !== plan.target;
388
+ if (fixByScript) await page.locator(arg.fields[plan.target]).first().focus({ timeout: 1500 }).catch(() => {});
389
+ await typeInto(page, f.fields[plan.target], longFor(f, plan.target), { fix: true });
390
+ a = await where();
391
+ for (let k = 0; k < 30 && !a.submit; k++) {
392
+ a = await press("Tab");
393
+ if (!a.inForm && !a.submit) break;
394
+ }
395
+ await inPage(page, "recover-arm", { ...arg, again: true });
396
+ const sentFix0 = writes();
397
+ const how2 = await submitBy();
398
+ await page.waitForTimeout(1200);
399
+ const r2 = await inPage(page, "recover-read", { ...arg, message: r1.message?.text ?? "" });
400
+ return { ...out, fix: { byScript: fixByScript, how: how2, r2, sent: writes() - sentFix0 } };
401
+ }
402
+ const fieldName = (x) => (x ? x.label || x.name || x.type : "the field");
403
+ // A long value, unless the field has a format of its own (a phone, a postcode, a number, a pattern): then the
404
+ // value that got past the form's validation, so only the field palate left invalid is wrong.
405
+ const longFor = (f, i) => {
406
+ const x = f.fields[i];
407
+ const v = f.values?.[i];
408
+ const strict = x.type === "tel" || x.type === "number" || x.rules?.pattern || /numeric|decimal|tel/.test(x.rules?.inputMode ?? "") || /phone|mobile|\btel\b|postal|post ?code|\bzip\b/i.test(`${x.name} ${x.label}`);
409
+ return strict && typeof v === "string" ? v : longValue(x);
410
+ };
411
+
412
+ // The findings of one recovery run (state "recover" at 1440, "recover-narrow" at 320).
413
+ const VALID_RE = /\b(required|invalid|enter (?:a|an|your|the)|must|please|valid|missing|format|too (?:long|short)|at (?:least|most)|can.?t be (?:blank|empty)|isn.?t|doesn.?t look|not a|looks? like|incorrect)\b|\b(error|wrong|couldn.?t|could not|failed|unable|problem|try again)/i;
414
+ export function recoveryFindings(f, r, state = "recover") {
415
+ if (!r || r.skipped || r.accepted || !r.r1) return [];
416
+ const out = [];
417
+ const width = state === "recover-narrow" ? 320 : 1440;
418
+ const { r1, plan } = r;
419
+ const t = plan.target;
420
+ // The first invalid field, in the form's order: one the page marked, or the one palate left invalid.
421
+ const firstBad = r1.fields.findIndex((x, i) => x && (x.ariaInvalid || (r1.nativeBlocked && x.nativeInvalid) || i === t));
422
+ const B = r1.fields[firstBad] ?? {};
423
+ const bName = fieldName(f.fields[firstBad]);
424
+ const msg = r1.message?.text ?? "";
425
+ const did = `Keyboard only at ${width}px, long values typed, ${plan.label} left ${plan.how === "empty" ? "empty" : `malformed ("${plan.bad.slice(0, 40)}…")`}, ${r.how}${r.byScript ? " (Tab from the top never reached the form; palate focused its first field)" : ` after ${r.tabs} Tab press(es) to reach the form`}`;
426
+ const relates = (s) => VALID_RE.test(s) || (msg && (s.includes(msg.slice(0, 40)) || msg.includes(s)));
427
+ const ref = (B.described ?? []).find((d) => d.visible && (d.fresh || VALID_RE.test(d.text) || (msg && (d.text.includes(msg) || msg.includes(d.text)))));
428
+ const native = r1.nativeBlocked && B.nativeInvalid;
429
+ // 1. Focus goes to the first invalid field, or an error summary that links to it.
430
+ const onField = r1.active.field === firstBad;
431
+ const onSummary = Boolean(r1.active.summary && B.id && r1.active.summary.links.includes(B.id));
432
+ if (!onField && !onSummary) add("focus-not-on-error", "high", `After a failed submit, keyboard focus stays ${r1.active.body ? "nowhere (it falls back to the page)" : `on ${r1.active.what}`}, not on the field to fix (${bName}): a keyboard or screen reader user has to hunt for it.`, `${did}: document.activeElement is ${r1.active.body ? "<body>" : r1.active.what}.`, "On a failed submit, move focus to the first invalid field (`field.focus()`), or to an error summary at the top of the form (`tabIndex={-1}`, role=\"alert\", a heading) with one link per error pointing at its field (`<a href=\"#email\">`).");
433
+ // 2. The field is marked invalid and its message tied to it.
434
+ const markedInvalid = B.ariaInvalid || native;
435
+ if (!native && !(markedInvalid && ref)) {
436
+ const why = !msg ? "no message says what's wrong" : !B.ariaInvalid ? `the field isn't marked invalid (no aria-invalid="true")${ref ? "" : `, and the message "${msg.slice(0, 60)}" isn't tied to it`}` : `the message "${msg.slice(0, 60)}" isn't tied to it (${(B.described ?? []).length ? `aria-describedby points at ${B.described.map((d) => `#${d.id} "${d.text.slice(0, 30)}"${d.visible ? "" : " (hidden or empty)"}`).join(", ")}` : "no aria-describedby or aria-errormessage"})`;
437
+ add("error-not-tied", "medium", `After a failed submit, ${bName}: ${why}. A screen reader on the field hears no error.`, `${did}: aria-invalid ${B.ariaInvalid ? '"true"' : "missing"}; described by ${(B.described ?? []).map((d) => `#${d.id}`).join(", ") || "nothing"}; message on screen: ${msg ? `"${msg.slice(0, 60)}"` : "none"}.`, "Set aria-invalid=\"true\" on the field and point aria-describedby (or aria-errormessage) at the message's id: `<input id=\"email\" aria-invalid=\"true\" aria-describedby=\"email-error\">` `<p id=\"email-error\">Enter an email address like name@example.com.</p>`. Show the message as text next to the field (not colour alone), and clear both when the value is fixed.");
438
+ }
439
+ // 3. The message is announced: a live region that was there (or role=alert) changed, or focus is on the
440
+ // field that describes it, or on a summary that is itself an alert or has a heading.
441
+ const live = (r1.live ?? []).filter((x) => !x.hidden && (x.existed || x.alert) && relates(x.text));
442
+ const late = (r1.live ?? []).filter((x) => !x.hidden && !x.existed && !x.alert);
443
+ const announced = live.length || native || (onField && ref) || (onSummary && (r1.active.summary.alert || r1.active.summary.labelled));
444
+ if (!announced) add("error-not-announced", "high", `The error is never announced: ${msg ? `"${msg.slice(0, 60)}" appears on screen` : "nothing appears"}, but no live region carries it and focus doesn't move to a field that describes it, so a screen reader user hears nothing after pressing ${f.submitText ? `"${f.submitText}"` : "submit"}.`, `${did}: ${late.length ? `a live region was added with the text already in it ("${late[0].text.slice(0, 50)}"), which screen readers often skip` : (r1.live ?? []).length ? `live regions changed but not with the error ("${r1.live[0].text.slice(0, 50)}")` : "no role=alert, role=status or aria-live region changed"}; focus on ${r1.active.body ? "<body>" : r1.active.what}.`, "Keep an empty live region in the page from the start and set the message into it on error (`<p id=\"email-error\" role=\"alert\"></p>`, or aria-live=\"polite\"), or move focus to the invalid field whose aria-describedby points at the message, so it is read with the field.");
445
+ // 4. Every typed value is still there.
446
+ const lost = (r.values ?? []).map((v, i) => (v && r1.fields[i] && r1.fields[i].value !== v ? i : -1)).filter((i) => i >= 0);
447
+ if (lost.length) add("values-lost", "high", `The failed submit throws away what the user typed: ${lost.map((i) => fieldName(f.fields[i])).join(", ")} ${lost.length > 1 ? "are" : "is"} ${lost.every((i) => !r1.fields[i].value) ? "empty" : "changed"} again.`, `${did}: ${lost.slice(0, 3).map((i) => `${fieldName(f.fields[i])} "${r.values[i].slice(0, 30)}…" → "${r1.fields[i].value.slice(0, 30)}"`).join("; ")}.`, "Keep every value on a failed submit: don't reset the form, remount the fields or clear state; clear only after a success. Leave the invalid value in place too, so it can be fixed instead of retyped.");
448
+ // 5. Field and message on screen without sideways scrolling, the focused field not under a sticky bar.
449
+ const off = [];
450
+ if (B.offX) off.push(`${bName} runs past the screen's edge (${B.box.left}–${B.box.right}px of ${r1.width}px)`);
451
+ if (r1.message?.offX) off.push(`the message "${msg.slice(0, 40)}" runs past the screen's edge (${r1.message.box.left}–${r1.message.box.right}px of ${r1.width}px)`);
452
+ const covered = onField || onSummary ? r1.active.coveredBy : null;
453
+ if (covered) off.push(`the focused ${onField ? "field" : "element"} sits under ${covered}`);
454
+ if (off.length) add("error-offscreen", "medium", `At ${width}px with long text, the error can't be seen without scrolling: ${off.join("; ")}.`, `${did}: page ${r1.overflowX ? `scrolls ${r1.overflowX}px sideways` : "doesn't scroll sideways"}.`, "Let the field and its message wrap inside the screen (`min-width: 0` on flex children, `max-width: 100%` on inputs, `overflow-wrap: anywhere` on the message), and add `scroll-padding-top` (the sticky header's height) on html so a focused field scrolls clear of it.");
455
+ // 6. Fixed by keyboard and sent again, the error clears.
456
+ const fx = r.fix?.r2;
457
+ if (fx) {
458
+ // Only the target's own error counts: a message about another field (a date palate couldn't type) that
459
+ // stays is that field's, not a sticky one.
460
+ const own = firstBad === t || (r1.fields[t]?.described ?? []).some((d) => msg && (d.text.includes(msg.slice(0, 40)) || msg.includes(d.text)));
461
+ const still = fx.fields[t]?.ariaInvalid || (fx.nativeBlocked && fx.fields[t]?.nativeInvalid) || (fx.stillShown && own);
462
+ if (still) add("error-sticks", "medium", `After ${plan.label} is fixed and the form sent again by keyboard, the error stays${fx.stillShown && msg ? ` ("${msg.slice(0, 50)}" still on screen)` : ""}${fx.fields[t]?.ariaInvalid ? " and the field is still aria-invalid" : ""}.`, `Fixed with a valid value${r.fix.byScript ? " (palate focused the field: the keyboard couldn't get back to it)" : " by Shift+Tab back to the field"}, ${r.fix.how}; ${r.fix.sent} write(s) sent.`, "Clear the error when the value becomes valid (on input, or on the next submit): remove aria-invalid, empty the message, and let the submit go through.");
463
+ }
464
+ return out;
465
+ function add(check, severity, what, measured, fix) {
466
+ out.push({ comp: f.id, state, check, severity, what, measured, fix, copy: check === "error-not-tied" && !msg ? `Name the field and the fix: "${formCopy(f).enter}"` : "" });
467
+ }
468
+ }
469
+
470
+ // The latest states session for a route, or null.
471
+ export function latestStates(dir, route) {
472
+ const base = path.join(dir, ".palate", "states", slugOf(route));
473
+ if (!fs.existsSync(base)) return null;
474
+ const done = fs.readdirSync(base).filter((s) => fs.existsSync(path.join(base, s, "findings.json"))).sort();
475
+ return done.length ? path.join(base, done.at(-1)) : null;
476
+ }
477
+
478
+ // The run a verify compares against: the first run of the current round, i.e. the latest run that wasn't
479
+ // itself a verify. Verifying twice still counts from there ("5 of 7 fixed since the first run").
480
+ export function originalStates(dir, route) {
481
+ const base = path.join(dir, ".palate", "states", slugOf(route));
482
+ if (!fs.existsSync(base)) return null;
483
+ const done = fs.readdirSync(base).filter((s) => fs.existsSync(path.join(base, s, "findings.json"))).sort();
484
+ const isVerify = (s) => fs.existsSync(path.join(base, s, "verify.md")) || Boolean(readJson(path.join(base, s, "findings.json"), {})?.verifyOf);
485
+ const first = [...done].reverse().find((s) => !isVerify(s));
486
+ return first ? path.join(base, first) : done.length ? path.join(base, done[0]) : null;
487
+ }
488
+
489
+ // The facts the user's rules read (rules.mjs), from the latest states run for a route.
490
+ export function statesFacts(dir, route) {
491
+ const s = latestStates(dir, route);
492
+ const f = s ? readJson(path.join(s, "findings.json"), null) : null;
493
+ return f?.facts ? { ...f.facts, statesRun: path.basename(s) } : null;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------------------------------------
497
+ export async function states(dir, rawRoute, { url, verify = false } = {}) {
498
+ const route = normalizeRoute(rawRoute);
499
+ const prev = verify ? originalStates(dir, route) : latestStates(dir, route);
500
+ if (verify && !prev) throw new Error(`No states run for ${route} to verify against. Run: palate states ${route}`);
501
+ const session = path.join(dir, ".palate", "states", slugOf(route), stamp());
502
+ const cropsDir = path.join(session, "crops");
503
+ fs.mkdirSync(cropsDir, { recursive: true });
504
+ fs.writeFileSync(path.join(dir, ".palate", ".gitignore"), "*\n");
505
+ const src = scanSource(dir, route);
506
+ const t0 = Date.now();
507
+
508
+ return withBrowser(dir, { url }, async ({ base, browser }) => {
509
+ const run = await renderAll(base, browser, route, session, cropsDir);
510
+ const where = {};
511
+ for (const c of run.components) where[c.id] = sourceOf(dir, c, src, route);
512
+ // A fetch in a form's own file is its submit (tested as a form), not the data on the screen.
513
+ const kind = dataKind(run.requests, src, run.components.filter((c) => c.kind === "form" && where[c.id]?.file).map((c) => where[c.id].file));
514
+ const findings = derive(run, { kind, src, where });
515
+ const facts = factsOf(run, findings, kind);
516
+ const sheet = await sheetOf(browser, run, findings, session).catch(() => null);
517
+ const record = {
518
+ route,
519
+ at: new Date().toISOString(),
520
+ ...(verify ? { verifyOf: path.basename(prev) } : {}),
521
+ seconds: Math.round((Date.now() - t0) / 1000),
522
+ data: { kind, requests: run.requests.map((r) => ({ method: r.method, url: r.url, status: r.status, json: r.json })) },
523
+ source: { entry: src.entry, files: src.files, fetches: src.fetches.slice(0, 12), maps: src.maps.slice(0, 20), loading: src.loading.length, error: src.error.length, empty: src.empty.length, server: src.server, staticArrays: src.staticArrays, conventions: src.conventions, ui: src.ui },
524
+ components: run.components.map((c) => ({ ...c, where: where[c.id] })),
525
+ actions: run.actions,
526
+ tested: (run.results.actions ?? []).map((a) => ({ text: a.text, sent: a.sent })),
527
+ ran: run.ran,
528
+ cells: run.cells,
529
+ timing500: run.results.many500?.comps ?? null,
530
+ recovery: recoveryOf(run.results),
531
+ cannot: run.cannot.concat(cannotOf(kind, src, run)),
532
+ findings,
533
+ facts,
534
+ sheet,
535
+ };
536
+ fs.writeFileSync(path.join(session, "findings.json"), JSON.stringify(record, null, 1));
537
+ const brief = briefOf(dir, route, record, session);
538
+ fs.writeFileSync(path.join(session, "states.md"), brief);
539
+ if (!verify) return { text: `${brief}\n\n(Brief saved to ${slash(path.join(session, "states.md"))})`, session, sheet, findings };
540
+ const v = await verifyOf(browser, prev, record, session);
541
+ return { text: v.text, session, sheet: v.sheet, findings };
542
+ });
543
+ }
544
+
545
+ // ---- every render
546
+ async function renderAll(base, browser, route, session, cropsDir) {
547
+ const full = base + route;
548
+ // Warm up: the dev server compiles the route and may reload once while it optimises dependencies.
549
+ {
550
+ const w = await visit(browser);
551
+ await go(w.page, full);
552
+ await settle(w.page, 15_000);
553
+ await w.close();
554
+ }
555
+ // The page as it is: what it fetches (and the JSON), its stateful parts, its text, its measurements.
556
+ const b = await visit(browser);
557
+ const responses = [];
558
+ b.page.on("response", async (res) => {
559
+ const req = res.request();
560
+ if (!isData(req)) return;
561
+ const rec = { method: req.method(), url: req.url(), status: res.status(), json: false };
562
+ responses.push(rec);
563
+ try {
564
+ if (/json/.test(res.headers()["content-type"] ?? "")) {
565
+ rec.body = await res.json();
566
+ rec.json = true;
567
+ }
568
+ } catch {}
569
+ });
570
+ await go(b.page, full);
571
+ await settle(b.page);
572
+ await scrollThrough(b.page, { pause: 70 });
573
+ await settle(b.page, 3000);
574
+ const dataStrings = [...stringsOf(responses.map((r) => r.body).filter(Boolean))];
575
+ const found = await inPage(b.page, "find", { dataStrings });
576
+ // Forms that look the same are tested once.
577
+ const formSeen = new Map();
578
+ for (const c of found.components) if (c.kind === "form") (formSeen.has(c.formSig) ? (c.sameAs = formSeen.get(c.formSig)) : formSeen.set(c.formSig, c.id));
579
+ const components = found.components;
580
+ const texts = await inPage(b.page, "texts");
581
+ const baseM = await inPage(b.page, "measure", { components, baseTexts: texts });
582
+ const pageShot = path.join(session, "page-1440.png");
583
+ await b.page.screenshot({ path: pageShot, fullPage: true }).catch(() => {});
584
+ const cells = Object.fromEntries(components.map((c) => [c.id, {}]));
585
+ const results = { default: baseM };
586
+ const ran = { default: { note: "" } };
587
+ const cannot = [];
588
+ for (const c of components) cells[c.id].default = await crop(b.page, baseM.comps[c.id]?.box ?? c.box, path.join(cropsDir, `${c.id}-default.png`), { pad: c.sig ? 12 : 8 });
589
+ const baseErrors = b.log.errors.slice();
590
+ await b.close();
591
+
592
+ const requests = responses.filter((r) => r.method === "GET" || r.method === "HEAD");
593
+ const fetched = requests.length > 0;
594
+ const data = components.filter((c) => !["form"].includes(c.kind));
595
+ const collections = components.filter((c) => c.sig && c.kind !== "form");
596
+
597
+ // A render under a state: mock, load, mark, optionally transform, measure, crop every part.
598
+ const stateRun = async (key, o = {}) => {
599
+ const v = await visit(browser, o);
600
+ try {
601
+ await go(v.page, full);
602
+ await settle(v.page);
603
+ if (o.scroll !== false) await scrollThrough(v.page, { pause: 60 }).catch(() => {});
604
+ await inPage(v.page, "mark", { components });
605
+ let transformed = null;
606
+ if (o.transform) {
607
+ transformed = await inPage(v.page, "transform", { mode: o.transform, n: 50, components: o.transformOn ?? collections });
608
+ await v.page.waitForTimeout(350);
609
+ }
610
+ const m = await inPage(v.page, "measure", { components, baseTexts: texts });
611
+ m.errors = v.log.errors.slice();
612
+ m.transformed = transformed;
613
+ m.sent = v.log.data.length;
614
+ results[key] = m;
615
+ for (const c of o.only ?? data) cells[c.id][key] = await crop(v.page, placeOf(m.comps[c.id], o.width ? null : baseM.comps[c.id]?.box ?? c.box, c), path.join(cropsDir, `${c.id}-${key}.png`), { pad: c.sig ? 12 : 8 });
616
+ // The first screen in every state, for findings about the page as a whole.
617
+ await v.page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })).catch(() => {});
618
+ await v.page.screenshot({ path: path.join(session, `page-${key}.png`) }).catch(() => {});
619
+ ran[key] = { note: o.note ?? "" };
620
+ return { v, m };
621
+ } finally {
622
+ if (!o.keep) await v.close();
623
+ }
624
+ };
625
+
626
+ const tasks = [];
627
+ if (fetched) {
628
+ tasks.push(async () => {
629
+ await stateRun("empty", { data: jsonMock((x) => shapeData(x, "empty")), note: "every array the app fetched answered empty" });
630
+ });
631
+ tasks.push(() => stateRun("error", { data: (r) => r.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Internal Server Error" }) }), note: "every data request answered 500" }));
632
+ tasks.push(() => stateRun("network", { data: (r) => r.abort("internetdisconnected"), note: "every data request failed (network down)" }));
633
+ tasks.push(() => loadingRun("loading"));
634
+ tasks.push(() => loadingRun("hang"));
635
+ }
636
+ const staticNote = fetched ? "" : " (in the DOM: the data is in the code)";
637
+ tasks.push(() => stateRun("images", { images: true, note: "every image answered 404", only: data.filter((c) => c.kind === "image" || baseM.comps[c.id]?.broken || c.images) }));
638
+ tasks.push(() => stateRun("narrow", { width: 320, height: 720, note: "320px wide (WCAG reflow)", fold: true, only: components.filter((c) => !c.sameAs) }));
639
+ tasks.push(() => keyboardRun());
640
+ for (const f of components.filter((c) => c.kind === "form" && !c.sameAs)) {
641
+ for (const mode of ["submitting", "submit-error", "offline", "empty-submit"]) tasks.push(() => formRun(f, mode));
642
+ tasks.push(() => lostRetryRun(f));
643
+ for (const w of [1440, 320]) tasks.push(() => recoverRun(f, w));
644
+ }
645
+ tasks.push(() => actionsRun());
646
+
647
+ // Long, many and one: the app's JSON when it fetches; the DOM for parts whose data is in the code.
648
+ const transformsAfterEmpty = () => {
649
+ const driven = new Set(fetched ? components.filter((c) => isDriven(c, baseM, results.empty)).map((c) => c.id) : []);
650
+ const dom = (list) => list.filter((c) => !driven.has(c.id));
651
+ return [
652
+ () => stateRun("long", { data: fetched ? jsonMock((x) => shapeData(x, "long")) : undefined, transform: "long", transformOn: dom(data.filter((c) => c.kind !== "image")), note: `names, emails and company names 3× long, one long unbroken word${staticNote}` }),
653
+ () => stateRun("many", { data: fetched ? jsonMock((x) => shapeData(x, "many", 50)) : undefined, transform: "many", transformOn: dom(collections), note: `every list at 50 items${staticNote}` }),
654
+ () => stateRun("one", { data: fetched ? jsonMock((x) => shapeData(x, "one")) : undefined, transform: "one", transformOn: dom(collections), note: `every list at one item${staticNote}` }),
655
+ ];
656
+ };
657
+
658
+ // Loading: data held for 5s; looked at 1.2s and 2.8s after the first request. No answer: held, looked at 8s.
659
+ async function loadingRun(key) {
660
+ const hold = key === "loading" ? (r) => sleep(LOAD_HOLD).then(() => r.fallback()) : (r, req, log) => new Promise((res) => log.held.push(res)).then(() => r.abort("timedout"));
661
+ const v = await visit(browser, { motion: true, data: hold });
662
+ try {
663
+ const nav = go(v.page, full, { wait: "domcontentloaded" });
664
+ const firstReq = await Promise.race([v.log.first, sleep(15_000).then(() => null)]);
665
+ await nav;
666
+ if (!firstReq) {
667
+ ran[key] = { note: "no data request during load" };
668
+ return;
669
+ }
670
+ const at = (ms) => sleep(Math.max(0, firstReq.at + ms - Date.now()));
671
+ await at(key === "loading" ? 1200 : 8000);
672
+ await inPage(v.page, "mark", { components }).catch(() => null);
673
+ const m = await inPage(v.page, "measure", { components, baseTexts: texts }).catch(() => null);
674
+ if (!m) return;
675
+ await v.page.screenshot({ path: path.join(session, `page-${key}.png`) }).catch(() => {});
676
+ const shots = {};
677
+ for (const c of data) {
678
+ const bx = placeOf(m.comps[c.id], baseM.comps[c.id]?.box ?? c.box, c);
679
+ cells[c.id][key] = await crop(v.page, bx, path.join(cropsDir, `${c.id}-${key}.png`));
680
+ if (key === "loading") shots[c.id] = { bx, a: cells[c.id][key] ? fs.readFileSync(cells[c.id][key]) : null };
681
+ }
682
+ if (key === "loading") {
683
+ await at(2800);
684
+ m.still = {};
685
+ for (const c of data) {
686
+ const s = shots[c.id];
687
+ if (!s?.a) continue;
688
+ const f2 = path.join(cropsDir, `${c.id}-loading-2.png`);
689
+ await crop(v.page, s.bx, f2);
690
+ m.still[c.id] = fs.existsSync(f2) && fs.readFileSync(f2).equals(s.a);
691
+ }
692
+ }
693
+ m.errors = v.log.errors.slice();
694
+ results[key] = m;
695
+ ran[key] = { note: key === "loading" ? `data held ${LOAD_HOLD / 1000}s; seen 1.2s and 2.8s after the first request` : "data never answered; seen 8s in" };
696
+ } finally {
697
+ await v.close();
698
+ }
699
+ }
700
+
701
+ // 500 records: how long each list takes to render them. Only parts whose items follow the records (at 50
702
+ // records they showed ~50 items; three stat cards stay three), each watched on its own clock: one part that
703
+ // never grows can't use up another's time.
704
+ // A slow first time is timed once more and the faster counts: a dev server and a busy machine add noise.
705
+ async function manyTiming(list) {
706
+ const parts = timedParts(list, baseM, results.many);
707
+ if (!parts.length) return;
708
+ const out = (await timeOnce(parts)) ?? {};
709
+ for (let k = 2; k <= 3; k++) {
710
+ const slow = parts.filter((p) => out[p.c.id]?.ms > SLOW_500);
711
+ if (!slow.length) break;
712
+ await sleep(1000);
713
+ const again = (await timeOnce(slow)) ?? {};
714
+ for (const p of slow) {
715
+ out[p.c.id].tries = k;
716
+ if (again[p.c.id]?.ms != null && again[p.c.id].ms < out[p.c.id].ms) out[p.c.id] = { ...again[p.c.id], tries: k };
717
+ }
718
+ }
719
+ results.many500 = { comps: out };
720
+ }
721
+ async function timeOnce(parts) {
722
+ let at = 0;
723
+ const v = await visit(browser, {
724
+ data: async (r, req) => {
725
+ await jsonMock((x) => shapeData(x, "many", 500))(r, req);
726
+ at = Date.now();
727
+ },
728
+ });
729
+ try {
730
+ await go(v.page, full, { wait: "domcontentloaded" });
731
+ // Watched in the page (no round trip per look): when each part reached its count, by the same clock.
732
+ const seen = await v.page.evaluate(({ sels, targets, limit }) => new Promise((done) => {
733
+ const out = {};
734
+ const t0 = Date.now();
735
+ const count = (s) => {
736
+ try {
737
+ return document.querySelector(s)?.children.length ?? 0;
738
+ } catch {
739
+ return 0;
740
+ }
741
+ };
742
+ const tick = () => {
743
+ sels.forEach((s, i) => {
744
+ if (out[i]) return;
745
+ const n = count(s);
746
+ if (n >= targets[i]) out[i] = { at: Date.now(), n };
747
+ });
748
+ if (Object.keys(out).length === sels.length || Date.now() - t0 > limit) done({ out, counts: sels.map(count) });
749
+ else setTimeout(tick, 16);
750
+ };
751
+ tick();
752
+ }), { sels: parts.map((p) => p.c.selector), targets: parts.map((p) => p.target), limit: 16_000 }).catch(() => null);
753
+ if (!seen) return null;
754
+ const out = {};
755
+ parts.forEach((p, i) => {
756
+ const hit = seen.out[i];
757
+ out[p.c.id] = hit ? { ms: at ? Math.max(0, hit.at - at) : null, items: hit.n } : { ms: null, items: seen.counts[i], timedOut: 15_000 };
758
+ });
759
+ return out;
760
+ } finally {
761
+ await v.close();
762
+ }
763
+ }
764
+
765
+ // 200% zoom on a 1440×900 screen (720×450 CSS px) and a keyboard-only pass: Tab through, and note focus
766
+ // that lands on something hidden, off screen, or under a sticky bar.
767
+ async function keyboardRun() {
768
+ const v = await visit(browser, { width: 720, height: 450, scale: 2 });
769
+ try {
770
+ await go(v.page, full);
771
+ await settle(v.page);
772
+ const m = await inPage(v.page, "measure", { components, baseTexts: texts });
773
+ await v.page.screenshot({ path: path.join(session, "page-zoom.png") }).catch(() => {});
774
+ const stops = [];
775
+ const issues = [];
776
+ for (let k = 0; k < 70; k++) {
777
+ await v.page.keyboard.press("Tab");
778
+ await v.page.waitForTimeout(70);
779
+ const f = await inPage(v.page, "focus").catch(() => null);
780
+ if (!f || f.body) continue;
781
+ if (f.sel === stops[0] && stops.length > 3) break;
782
+ if (stops.includes(f.sel)) continue;
783
+ stops.push(f.sel);
784
+ if (f.hidden || !f.inView || f.obscured) {
785
+ if (issues.length < 8 && !issues.some((i) => i.sel === f.sel)) {
786
+ const file = path.join(cropsDir, `focus-${issues.length + 1}.png`);
787
+ await v.page.screenshot({ path: file }).catch(() => {});
788
+ issues.push({ ...f, file });
789
+ }
790
+ }
791
+ }
792
+ m.focusIssues = issues;
793
+ m.stops = stops.length;
794
+ results.zoom = m;
795
+ ran.zoom = { note: `720×450 at 2× (200% of 1440×900); Tab pressed through ${stops.length} stops` };
796
+ } finally {
797
+ await v.close();
798
+ }
799
+ }
800
+
801
+ // A form: filled with test values and sent; every send answered by palate after a pause.
802
+ async function formRun(f, mode) {
803
+ const delay = mode === "submitting" ? 3000 : 500;
804
+ const v = await visit(browser, {
805
+ motion: true,
806
+ data: async (r, req) => {
807
+ // Route mocks answer before the browser's offline check, so offline has to fail here too.
808
+ if (mode === "offline") return r.abort("internetdisconnected");
809
+ if (isRead(req)) return r.fallback();
810
+ await sleep(delay);
811
+ if (mode === "submit-error") return r.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ ok: false, error: "Internal Server Error" }) });
812
+ return r.fulfill(okJson);
813
+ },
814
+ });
815
+ try {
816
+ await go(v.page, full);
817
+ await settle(v.page);
818
+ await inPage(v.page, "mark", { components: [f] });
819
+ if (mode !== "empty-submit") await fill(v.page, f);
820
+ const filled = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel) });
821
+ if (mode === "offline") await v.ctx.setOffline(true);
822
+ const before = v.log.data.length;
823
+ const t = Date.now();
824
+ const btn = v.page.locator(f.submit).first();
825
+ await btn.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
826
+ let clicked = await btn.click({ timeout: 3000 }).then(() => true).catch(() => false);
827
+ // Two presses racing, 30ms apart (dispatched in the page: a locator click waits too long to race).
828
+ if (mode === "submitting" && clicked) {
829
+ await v.page.waitForTimeout(30);
830
+ await v.page.evaluate((sel) => document.querySelector(sel)?.click(), f.submit).catch(() => {});
831
+ }
832
+ // The transition, not only the end: the button looked at three times while the 3s request runs.
833
+ const samples = [];
834
+ if (mode === "submitting" && clicked) {
835
+ for (const at of [120, 1200, 2500]) {
836
+ await sleep(Math.max(0, t + at - Date.now()));
837
+ const s = await inPage(v.page, "measure", { components: [], baseTexts: texts, submit: f.submit }).catch(() => null);
838
+ samples.push({ at, cue: Boolean(s?.submit && (s.submit.disabled || s.submit.ariaDisabled || s.submit.busy || s.submit.cue || (s.submit.text && s.submit.text !== f.submitText))) });
839
+ }
840
+ } else await v.page.waitForTimeout(350);
841
+ const pending = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel), submit: f.submit });
842
+ const box = (m) => placeOf(m.comps[f.id], baseM.comps[f.id]?.box ?? f.box, f);
843
+ if (mode === "submitting") cells[f.id][mode] = await crop(v.page, box(pending), path.join(cropsDir, `${f.id}-${mode}.png`), { pad: 8 });
844
+ await v.page.waitForTimeout(mode === "submitting" ? 3300 : 1600);
845
+ const after = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel), submit: f.submit });
846
+ const focus = await inPage(v.page, "focus").catch(() => null);
847
+ if (mode !== "submitting") cells[f.id][mode] = await crop(v.page, box(after), path.join(cropsDir, `${f.id}-${mode}.png`), { pad: 8 });
848
+ else cells[f.id]["submitted"] = await crop(v.page, box(after), path.join(cropsDir, `${f.id}-submitted.png`), { pad: 8 });
849
+ const sent = v.log.data.slice(before).filter((d) => d.at - t < 2500 && !isReadMethod(d.method)).length;
850
+ const outcome = mode === "submitting" ? await inPage(v.page, "outcome", { baseTexts: texts, values: filled.fields }).catch(() => null) : null;
851
+ results[`${f.id}:${mode}`] = { clicked, sent, filled: filled.fields, pending, after, focus, samples, outcome, errors: v.log.errors.slice() };
852
+ ran[mode] = { note: { submitting: "filled with test values, pressed twice 30ms apart, answered after 3s; the button looked at 0.1s, 1.2s and 2.5s", "submit-error": "filled, sent, answered 500", offline: "filled, browser offline, sent", "empty-submit": "sent with every field empty" }[mode] };
853
+ } finally {
854
+ await v.close();
855
+ }
856
+ }
857
+
858
+ // Saved, but the answer was lost: the first send reaches palate (standing in for the server, which never
859
+ // sees it) and its response is dropped, so the client sees a network error. Then the user retries the way
860
+ // the UI offers (its Retry button, or the submit again) and palate answers that one. Which writes arrived,
861
+ // with which headers and body, and what the UI shows after.
862
+ async function lostRetryRun(f) {
863
+ const writes = [];
864
+ const reads = [];
865
+ const v = await visit(browser, {
866
+ motion: true,
867
+ data: async (r, req) => {
868
+ if (isRead(req)) {
869
+ if (writes.length) reads.push({ at: Date.now(), after: writes.length });
870
+ return r.fallback();
871
+ }
872
+ writes.push({ at: Date.now(), url: req.url(), headers: req.headers(), body: req.postData() ?? "" });
873
+ if (writes.length === 1) {
874
+ await sleep(600);
875
+ return r.abort("timedout");
876
+ }
877
+ await sleep(400);
878
+ return r.fulfill(okJson);
879
+ },
880
+ });
881
+ try {
882
+ await go(v.page, full);
883
+ await settle(v.page);
884
+ await inPage(v.page, "mark", { components: [f] });
885
+ await fill(v.page, f);
886
+ const filled = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel) });
887
+ const btn = v.page.locator(f.submit).first();
888
+ await btn.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
889
+ const clicked = await btn.click({ timeout: 3000 }).then(() => true).catch(() => false);
890
+ await v.page.waitForTimeout(1600);
891
+ const lost = await inPage(v.page, "outcome", { baseTexts: texts, values: filled.fields });
892
+ const failed = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel), submit: f.submit });
893
+ let how = null;
894
+ if (clicked && writes.length === 1) {
895
+ if (lost.retry) {
896
+ how = "its Try again button";
897
+ await v.page.locator(lost.retry).first().click({ timeout: 2000 }).catch(() => (how = null));
898
+ }
899
+ if (!how) {
900
+ // The form as the user left it: what it still holds, refilled only if it cleared.
901
+ if ((failed.fields ?? []).every((x) => !x)) await fill(v.page, f);
902
+ how = "pressing the submit again";
903
+ await btn.click({ timeout: 3000 }).catch(() => (how = null));
904
+ }
905
+ await v.page.waitForTimeout(1600);
906
+ }
907
+ const end = await inPage(v.page, "outcome", { baseTexts: texts, values: filled.fields });
908
+ const after = await inPage(v.page, "measure", { components: [f], baseTexts: texts, fields: f.fields.map((x) => x.sel), submit: f.submit });
909
+ cells[f.id]["lost-retry"] = await crop(v.page, placeOf(after.comps[f.id], baseM.comps[f.id]?.box ?? f.box, f), path.join(cropsDir, `${f.id}-lost-retry.png`), { pad: 8 });
910
+ results[`${f.id}:lost-retry`] = { clicked, how, writes, checked: reads.some((x) => x.after === 1 && writes[1] && x.at < writes[1].at), idempotent: idempotencyOf(writes[0], writes[1]), lost, end, failedMsg: failed.comps[f.id]?.errorMsg ?? failed.errorTexts?.[0] ?? null, errors: v.log.errors.slice() };
911
+ ran["lost-retry"] = { note: "filled and sent; the first send reached palate (the server never sees it) and its answer was dropped, then retried the way the UI offers, answered 200" };
912
+ } finally {
913
+ await v.close();
914
+ }
915
+ }
916
+
917
+ // A failed submit by keyboard only, at 1440 and 320, with long values: focus, message, announcement, values
918
+ // kept, on screen, and fixed by keyboard. Every write answered by palate.
919
+ async function recoverRun(f, width) {
920
+ const key = width < 400 ? "recover-narrow" : "recover";
921
+ const v = await visit(browser, { width, height: width < 400 ? 720 : 900, motion: true, data: async (r, req) => (isRead(req) ? r.fallback() : (await sleep(300), r.fulfill(okJson))) });
922
+ try {
923
+ await go(v.page, full);
924
+ await settle(v.page);
925
+ await inPage(v.page, "mark", { components: [f] });
926
+ const shot = async (r1) => {
927
+ const b = await v.page.locator(`[data-palate-c="${f.id}"]`).first().boundingBox().catch(() => null);
928
+ const sy = await v.page.evaluate(() => scrollY).catch(() => 0);
929
+ const m = r1.message?.box;
930
+ const x0 = Math.min(b?.x ?? m?.left ?? 0, m?.left ?? Infinity);
931
+ const y0 = Math.min(b?.y ?? m?.top ?? 0, m?.top ?? Infinity);
932
+ const x1 = Math.max(b ? b.x + b.width : 0, m?.right ?? 0);
933
+ const y1 = Math.max(b ? b.y + b.height : 0, m?.bottom ?? 0);
934
+ if (b || m) cells[f.id][key] = await crop(v.page, { x: x0, y: y0 + sy, width: x1 - x0, height: y1 - y0 }, path.join(cropsDir, `${f.id}-${key}.png`), { pad: 10 });
935
+ await v.page.screenshot({ path: path.join(cropsDir, `${f.id}-${key}-screen.png`) });
936
+ };
937
+ const r = await keyboardRecovery(v.page, f, { writes: () => v.log.data.filter((d) => !isReadMethod(d.method)).length, shot });
938
+ results[`${f.id}:${key}`] = { ...r, screen: fs.existsSync(path.join(cropsDir, `${f.id}-${key}-screen.png`)) ? path.join(cropsDir, `${f.id}-${key}-screen.png`) : null };
939
+ const name = `${f.id} form${f.heading ? ` "${f.heading}"` : ""}`;
940
+ if (r.skipped) width > 400 && cannot.push(`Keyboard error recovery (${name}): ${r.skipped}.`);
941
+ else if (r.accepted) cannot.push(`Keyboard error recovery (${name}, ${width}px): the form sent ${r.plan.label} ${r.plan.how === "empty" ? "empty" : `as "${r.plan.bad.slice(0, 40)}…"`} without complaint. palate answers every send with 200, so a server-side validation error couldn't be seen.`);
942
+ ran[key] = { note: `keyboard only at ${width}px: Tab into each form, long values typed, one field left invalid, Enter on the submit; then Shift+Tab back, fixed, sent again` };
943
+ } finally {
944
+ await v.close();
945
+ }
946
+ }
947
+
948
+ // Buttons outside forms, each pressed twice quickly with every request held: the ones that send something.
949
+ async function actionsRun() {
950
+ if (!found.actions.length) return;
951
+ const v = await visit(browser, { motion: true, data: async (r, req) => (await sleep(2500), isRead(req) ? r.fallback() : r.fulfill(okJson)) });
952
+ const out = [];
953
+ try {
954
+ await go(v.page, full);
955
+ await settle(v.page);
956
+ for (const a of found.actions) {
957
+ const loc = v.page.locator(a.sel).first();
958
+ if (!(await loc.isVisible().catch(() => false))) continue;
959
+ const before = v.log.data.length;
960
+ const url0 = v.page.url();
961
+ await loc.click({ timeout: 1500 }).catch(() => {});
962
+ await v.page.waitForTimeout(80);
963
+ await loc.click({ timeout: 600, force: true }).catch(() => {});
964
+ await v.page.waitForTimeout(450);
965
+ const sent = v.log.data.length - before;
966
+ if (sent > 0) {
967
+ const m = await inPage(v.page, "measure", { components: [], baseTexts: texts, submit: a.sel });
968
+ const bx = await loc.boundingBox().catch(() => null);
969
+ const file = bx ? await crop(v.page, { x: bx.x, y: bx.y + (await v.page.evaluate(() => scrollY)), width: bx.width, height: bx.height }, path.join(cropsDir, `action-${out.length + 1}.png`), { pad: 40 }) : null;
970
+ out.push({ ...a, sent, submit: m.submit, file });
971
+ await v.page.waitForTimeout(2600);
972
+ }
973
+ if (v.page.url() !== url0) {
974
+ await go(v.page, full);
975
+ await settle(v.page);
976
+ }
977
+ }
978
+ results.actions = out;
979
+ ran.pressed = { note: `${found.actions.length} button(s) outside forms pressed twice 80ms apart; ${out.length} sent a request` };
980
+ } finally {
981
+ await v.close();
982
+ }
983
+ }
984
+
985
+ // Before any form is tested: test values its own validation accepts (every write answered 200).
986
+ async function probeRun(f) {
987
+ const v = await visit(browser, { motion: true, data: async (r, req) => (isRead(req) ? r.fallback() : (await sleep(200), r.fulfill(okJson))) });
988
+ try {
989
+ await go(v.page, full);
990
+ await settle(v.page);
991
+ await inPage(v.page, "mark", { components: [f] });
992
+ const p = await probeValues(v.page, f, { writes: () => v.log.data.filter((d) => !isReadMethod(d.method)).length, baseTexts: texts });
993
+ f.values = p.values;
994
+ f.probe = { passed: p.passed, sent: p.sent, rounds: p.rounds, message: p.message ?? null, blocked: p.blocked ?? [] };
995
+ } finally {
996
+ await v.close();
997
+ }
998
+ }
999
+
1000
+ // Three at a time; empty first, since long / many / one need to know which parts its data drives.
1001
+ const pool = async (list, n = 3) => {
1002
+ const q = [...list];
1003
+ await Promise.all(Array.from({ length: Math.min(n, q.length) }, async () => {
1004
+ while (q.length) {
1005
+ const t = q.shift();
1006
+ await t().catch((e) => cannot.push(`A render failed: ${String(e.message).split("\n")[0].slice(0, 160)}`));
1007
+ }
1008
+ }));
1009
+ };
1010
+ await pool(components.filter((c) => c.kind === "form" && !c.sameAs).map((f) => () => probeRun(f)));
1011
+ if (fetched) await pool([tasks.shift()], 1);
1012
+ await pool([...tasks, ...transformsAfterEmpty()]);
1013
+ // 500 records last and alone, so other renders don't slow the timing; the 50-item render says which parts'
1014
+ // items follow the records.
1015
+ if (fetched) {
1016
+ const timed = collections.filter((c) => isDriven(c, baseM, results.empty));
1017
+ if (timed.length) await manyTiming(timed).catch((e) => cannot.push(`A render failed: ${String(e.message).split("\n")[0].slice(0, 160)}`));
1018
+ }
1019
+
1020
+ return { components, actions: found.actions, title: found.title, requests, responses, baseM, baseErrors, results, cells, ran, cannot, pageShot, fetched };
1021
+ }
1022
+ const isReadMethod = (m) => m === "GET" || m === "HEAD";
1023
+
1024
+ // Whether a retried write says it is the same write: an idempotency or request-id header with the same value
1025
+ // on both, or a client-made id in the body (a key named like one, or any UUID) that repeats. What the user
1026
+ // typed doesn't count: two invites to the same email are still two writes.
1027
+ const ID_HEADER = /^(idempotency-key|x-idempotency-key|x-request-id|request-id|x-client-request-id|x-correlation-id|x-mutation-id)$/i;
1028
+ const ID_KEY = /^(id|uuid|key|nonce|client_?id|request_?id|idempotency_?key|client_?mutation_?id|mutation_?id|operation_?id|submission_?id|draft_?id|temp_?id|local_?id)$/i;
1029
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1030
+ const bodyIds = (body) => {
1031
+ const out = new Map();
1032
+ let o = null;
1033
+ try {
1034
+ o = JSON.parse(body);
1035
+ } catch {
1036
+ try {
1037
+ o = Object.fromEntries(new URLSearchParams(body));
1038
+ } catch {}
1039
+ }
1040
+ const walk = (v, k, d) => {
1041
+ if (d > 4 || v == null) return;
1042
+ if (typeof v === "object") return Object.entries(v).forEach(([k2, x]) => walk(x, k2, d + 1));
1043
+ const s = String(v);
1044
+ if ((ID_KEY.test(k) && s.length >= 6) || UUID.test(s)) out.set(`${k}=${s}`, k);
1045
+ };
1046
+ walk(o, "", 0);
1047
+ return out;
1048
+ };
1049
+ export function idempotencyOf(a, b) {
1050
+ if (!a || !b) return null;
1051
+ for (const [k, v] of Object.entries(a.headers ?? {})) if (ID_HEADER.test(k) && v && b.headers?.[k.toLowerCase()] === v) return `the ${k} header repeats`;
1052
+ const ids = bodyIds(b.body ?? "");
1053
+ for (const [kv, k] of bodyIds(a.body ?? "")) if (ids.has(kv)) return `"${k || "id"}" in the body repeats`;
1054
+ return null;
1055
+ }
1056
+
1057
+ // The parts worth timing at 500 records: their item count followed the records at 50 (a list of 7 showed
1058
+ // ~50), so at 500 they should show ~500. Stat cards and capped lists don't grow and aren't timed.
1059
+ export function timedParts(list, baseM, many) {
1060
+ return list
1061
+ .map((c) => {
1062
+ const b = baseM?.comps?.[c.id]?.count ?? 0;
1063
+ const m = many?.comps?.[c.id]?.count ?? 0;
1064
+ return { c, follows: m >= 40 && m > b, target: Math.round(m * 10 * 0.9) };
1065
+ })
1066
+ .filter((p) => p.follows);
1067
+ }
1068
+
1069
+ // A part's content comes from the fetched data when emptying the data changed it.
1070
+ function isDriven(c, baseM, empty) {
1071
+ if (!empty) return false;
1072
+ const b = baseM.comps[c.id];
1073
+ const e = empty.comps[c.id];
1074
+ if (!b || !e) return false;
1075
+ if (!e.present && b.present) return true;
1076
+ if (c.sig && e.count !== b.count) return true;
1077
+ return e.scopeText !== b.scopeText;
1078
+ }
1079
+
1080
+ // Where a part lives in the source: the best file:line by its text and classes, what is mapped near it, and
1081
+ // whether that file handles loading, errors and empty arrays at all.
1082
+ // Only files the route's entry reaches count (whereIs, states-source.mjs); when the clues disagree it says
1083
+ // "not sure" instead of naming a file.
1084
+ function sourceOf(dir, c, src, route) {
1085
+ const top = whereIs(dir, c, src, route);
1086
+ if (!top) return null;
1087
+ if (top.unsure) return { unsure: true, guesses: top.guesses };
1088
+ const file = top.file;
1089
+ const near = src.maps.filter((m) => m.file === file && Math.abs(m.line - top.line) <= 12).sort((a, b) => Math.abs(a.line - top.line) - Math.abs(b.line - top.line))[0] ?? null;
1090
+ const has = (list) => list.some((x) => x.file === file);
1091
+ return { file, line: top.line, map: near ? { line: near.line, over: near.over } : null, handles: { loading: has(src.loading), error: has(src.error), empty: has(src.empty) }, fetch: src.fetches.find((x) => x.file === file) ?? null, static: src.staticArrays.find((s) => s.file === file && near && near.over.split(".")[0] === s.name) ?? null };
1092
+ }
1093
+
1094
+ // ---- findings
1095
+ const SEV = ["high", "medium", "low"];
1096
+ const label = (c) => `${c.id} ${c.kind}${c.heading ? ` "${c.heading}"` : c.texts?.[0] ? ` "${c.texts[0].slice(0, 32)}"` : ""}`;
1097
+ const noun = (c) => ({ list: "list", table: "table", cards: "cards", grid: "grid", form: "form", number: "number", image: "image", text: "text" })[c.kind] ?? "part";
1098
+ const uiName = (ui, role) => (ui[role] ? `${path.basename(ui[role]).replace(/\.\w+$/, "")} (${ui[role]})` : null);
1099
+
1100
+ // What a part holds, in its own words, for the copy a finding suggests: its heading when that names records
1101
+ // ("This week's firings" → firings), else the request whose records it shows (/api/firings.json → firings),
1102
+ // else its heading, else every request's records. Never another part's subject.
1103
+ const recordsOf = (url) => {
1104
+ try {
1105
+ return decodeURIComponent(new URL(url).pathname).split("/").filter(Boolean).at(-1)?.replace(/\.\w+$/, "").replace(/[-_]+/g, " ").toLowerCase() || null;
1106
+ } catch {
1107
+ return null;
1108
+ }
1109
+ };
1110
+ const PEOPLE = /^(members|users|people|team|teammates|collaborators|guests|students|customers|contacts|employees|staff|clients|patients|attendees|participants)$/;
1111
+ const singular = (w) => (/ies$/.test(w) ? w.replace(/ies$/, "y") : /(ss|us)$/.test(w) ? w : w.replace(/s$/, ""));
1112
+ export function subjectOf(c, requests = []) {
1113
+ const h = (c.heading ?? "").trim();
1114
+ const last = (h.replace(/['’]s\b/g, "").match(/[A-Za-z]+/g) ?? []).at(-1)?.toLowerCase();
1115
+ const lower = (s) => s.charAt(0).toLowerCase() + s.slice(1);
1116
+ if (last && last.length >= 3 && /[^s]s$/.test(last)) return { noun: last, phrase: lower(h) };
1117
+ const texts = (c.texts ?? []).join(" ");
1118
+ let best = null;
1119
+ for (const r of requests) {
1120
+ const hits = [...stringsOf(r.body ?? null)].filter((s) => s.length >= 3 && texts.includes(s)).length;
1121
+ if (hits && (!best || hits > best.hits)) best = { hits, noun: recordsOf(r.url) };
1122
+ }
1123
+ if (best?.noun) return { noun: best.noun, phrase: best.noun };
1124
+ if (last && last.length >= 3) return { noun: last, phrase: lower(h) };
1125
+ const all = [...new Set(requests.map((r) => recordsOf(r.url)).filter(Boolean))];
1126
+ if (all.length) return { noun: all.join(" and "), phrase: all.join(" and ") };
1127
+ return { noun: "records", phrase: "this" };
1128
+ }
1129
+ export function copyFor(c, requests, kind) {
1130
+ const { noun: n, phrase } = subjectOf(c, requests);
1131
+ const one = singular(n.split(" and ")[0]);
1132
+ const verb = PEOPLE.test(n) ? "Invite" : "Add";
1133
+ return {
1134
+ empty: `Say what is empty and what to do next: "No ${n} yet." with one line on why it matters, and the action that adds the first one as a button ("${verb} a ${one}"). Never "No data".`,
1135
+ loading: `A skeleton needs no words; if you use text, say what is loading ("Loading ${phrase}…").`,
1136
+ error: `Say what happened and what to do: "Couldn't load ${phrase}. Check your connection and try again." with a "Try again" button. No codes, no blame.`,
1137
+ }[kind];
1138
+ }
1139
+
1140
+ // A form's own words: its button ("Invite member" → "Couldn't invite member.") and its first required field.
1141
+ export function formCopy(f) {
1142
+ const act = (f.submitText ?? "").trim();
1143
+ const field = f.fields?.find((x) => x.required) ?? f.fields?.[0];
1144
+ const what = field ? (field.type === "email" || /e-?mail/i.test(`${field.name} ${field.label}`) ? "an email address" : field.label && !/@|\.\w/.test(field.label) ? field.label.toLowerCase().replace(/^(an?|the|your)\s+/, "") : null) : null;
1145
+ return {
1146
+ failed: act && act.split(/\s+/).length <= 4 ? `Couldn't ${act.charAt(0).toLowerCase()}${act.slice(1)}. Try again in a moment.` : "Couldn't send it. Try again in a moment.",
1147
+ enter: what ? (what.startsWith("an ") || what.startsWith("a ") ? `Enter ${what}.` : `Enter ${/^[aeiou]/.test(what) ? "an" : "a"} ${what}.`) : "Name the field that's missing and what to put in it.",
1148
+ };
1149
+ }
1150
+
1151
+ // A render that crashed: the app's root emptied, a dev error overlay, or the page went blank with an error.
1152
+ // A blank page with no error counts only when the data failed (elsewhere it is a missing state, not a crash).
1153
+ export const crashedIn = (m, baseM, s = "error") => Boolean(m && (m.rootEmpty || m.overlay || ((m.errors?.length || (["error", "network"].includes(s) && (baseM?.bodyText ?? 0) >= 120)) && m.bodyText < 40)));
1154
+
1155
+ function derive(run, { kind, src, where }) {
1156
+ const { components, results: R, baseM } = run;
1157
+ const out = [];
1158
+ const add = (f) => {
1159
+ // One finding per part and check; a second state that shows it joins the first.
1160
+ const twin = out.find((x) => x.comp === f.comp && x.check === f.check);
1161
+ if (twin) {
1162
+ if (!twin.states.includes(f.state)) twin.states.push(f.state);
1163
+ if (f.affects) twin.affects = { ...(twin.affects ?? {}), ...f.affects };
1164
+ return;
1165
+ }
1166
+ out.push({ ...f, states: [f.state] });
1167
+ };
1168
+ // A page that crashed in a state: one finding, and every part it took down fails in that state.
1169
+ const crashed = new Set();
1170
+ // (Not while loading: a page may hold nothing but a spinner then.)
1171
+ for (const s of ["empty", "error", "network", "long", "many", "one", "images", "narrow"]) {
1172
+ const x = R[s];
1173
+ if (!crashedIn(x, baseM, s)) continue;
1174
+ crashed.add(s);
1175
+ const gone = components.filter((c) => baseM.comps[c.id]?.present && !x.comps?.[c.id]?.present).map((c) => c.id);
1176
+ const fails = s === "error" || s === "network";
1177
+ const reqs0 = run.requests.map((r) => `${r.method} ${new URL(r.url).pathname}`).filter((v, i, a) => a.indexOf(v) === i).join(", ");
1178
+ add({ comp: "page", state: s, check: "crash", severity: "high", what: `${fails ? "When the data fails" : `In the "${STATES[s]}" state`}, the page crashes${x.bodyText < 40 || x.rootEmpty ? " and goes blank" : ""}${x.errors?.length ? ` (${x.errors[0].slice(0, 90)})` : x.overlay ? " (the dev server's error overlay)" : ""}.`, measured: `${s === "error" ? `A 500 on ${reqs0}` : s === "network" ? `A network failure on ${reqs0}` : STATES[s]}: ${x.errors?.length ? "a runtime error, " : ""}${x.bodyText} characters left on the page${gone.length ? `; ${gone.join(", ")} gone` : ""}.`, fix: fails ? "Check the response before using it (`if (!res.ok) throw …`), keep the data's type when it fails, and catch the error into state the screen can render. Add an error boundary around the screen." : "Guard the data path for this shape (empty arrays, missing fields, long values), and add an error boundary around the screen so one part can't take the page down.", copy: "", crop: fs.existsSync(path.join(path.dirname(run.pageShot), `page-${s}.png`)) ? path.join(path.dirname(run.pageShot), `page-${s}.png`) : null, affects:{ [s]: gone.length ? gone : components.map((c) => c.id) } });
1179
+ }
1180
+ // A page that scrolls sideways: the parts reaching past the screen's edge fail in that state too.
1181
+ const sideways = (s) => components.filter((c) => (R[s]?.comps?.[c.id]?.past ?? 0) > 1).map((c) => c.id);
1182
+ const ui = src.ui;
1183
+ const loc = (c) => (where[c.id]?.unsure ? `not sure (the clues point at ${where[c.id].guesses.join(" and ")}; check before editing)` : where[c.id] ? `${where[c.id].file}:${where[c.id].line}${where[c.id].map ? ` (\`${where[c.id].map.over}.map\` at line ${where[c.id].map.line})` : ""}` : null);
1184
+ const over = (c) => where[c.id]?.map?.over ?? "the array";
1185
+ const reqs = run.requests.map((r) => `${r.method} ${new URL(r.url).pathname}`).filter((v, i, a) => a.indexOf(v) === i).join(", ");
1186
+ const driven = new Set(components.filter((c) => isDriven(c, baseM, R.empty)).map((c) => c.id));
1187
+ const crop = (c, s) => run.cells[c.id]?.[s] ?? null;
1188
+
1189
+ for (const c of components) {
1190
+ if (c.kind === "form") continue;
1191
+ const d = driven.has(c.id);
1192
+ // Empty
1193
+ const e = crashed.has("empty") ? null : R.empty?.comps[c.id];
1194
+ if (d && e && (c.sig ? !e.present || e.count === 0 : true)) {
1195
+ if (!e.emptyMsg) add({ comp: c.id, state: "empty", check: "missing-empty", severity: "high", what: `The ${noun(c)} has no empty state: with no records it shows ${e.present ? `an empty ${c.tag} and nothing in its place` : "nothing at all where it was"}.`, measured: `${reqs} answered with empty arrays; ${e.present ? `${e.count ?? 0} items rendered` : "the part was gone"}, and no message or action appeared in its place.`, fix: `Branch on \`${over(c)}.length === 0\` and render an empty state in the space the ${noun(c)} takes: what is empty, why it matters in one line, and the next action as a button${ui.empty ? `; use ${uiName(ui, "empty")}` : ""}.`, copy: copyFor(c, run.requests, "empty"), crop: crop(c, "empty") });
1196
+ else if (!e.actions.length && ["list", "table", "cards", "grid"].includes(c.kind)) add({ comp: c.id, state: "empty", check: "empty-no-action", severity: "low", what: `The empty state says "${e.emptyMsg.slice(0, 60)}" but offers nothing to do next.`, measured: "No button or link in the empty region.", fix: "Add the next action as a button in the empty state (create, invite, import, clear the filters).", copy: "Pair the message with the one action that fills the list.", crop: crop(c, "empty") });
1197
+ }
1198
+ // Loading
1199
+ const l = crashed.has("loading") ? null : R.loading?.comps[c.id];
1200
+ if (d && l) {
1201
+ const shown = l.present && (c.sig ? l.count > 0 : true) && !l.newTexts.length && l.scopeText === baseM.comps[c.id]?.scopeText;
1202
+ if (!shown) {
1203
+ if (l.emptyMsg && !l.loadingCue) add({ comp: c.id, state: "loading", check: "empty-while-loading", severity: "high", what: `While the data loads, the ${noun(c)} says "${l.emptyMsg.slice(0, 60)}": it claims there is nothing before it knows.`, measured: `1.2s into a ${LOAD_HOLD / 1000}s load the empty message shows and no loading cue does.`, fix: "Keep loading and empty apart: show the loading state until the request settles; render the empty state only when it succeeded with no records.", copy: "", crop: crop(c, "loading") });
1204
+ else if (!l.loadingCue && !R.loading.cues.length) add({ comp: c.id, state: "loading", check: "missing-loading", severity: "high", what: `Nothing shows that the ${noun(c)} is loading: ${l.present ? "it sits empty" : "its space is blank"} until the data arrives.`, measured: `Data held ${LOAD_HOLD / 1000}s: no skeleton, spinner, aria-busy or "Loading" text in its region${R.loading.still?.[c.id] ? ", and nothing on it changed between 1.2s and 2.8s" : ""}.`, fix: `Track the request (\`loading\` until it settles) and render a skeleton shaped like the ${noun(c)}'s rows in its place, with aria-busy on the region${ui.skeleton ? `; use ${uiName(ui, "skeleton")}` : ui.spinner ? `; use ${uiName(ui, "spinner")}` : ""}. Show it only after ~300ms so fast loads don't flash.`, copy: copyFor(c, run.requests, "loading"), crop: crop(c, "loading") });
1205
+ }
1206
+ }
1207
+ // Errors: 500 and network down
1208
+ for (const s of ["error", "network"]) {
1209
+ const x = R[s]?.comps[c.id];
1210
+ // A crash is one finding for the page (above), with this part failing in it.
1211
+ if (!d || !x || crashed.has(s)) continue;
1212
+ if (!x.errorMsg && !R[s].errorTexts.length) add({ comp: c.id, state: s, check: "error-swallowed", severity: "high", what: `When the data fails to load, nothing says so: the ${noun(c)} ${x.emptyMsg ? `shows its empty state ("${x.emptyMsg.slice(0, 40)}") as if there were no records` : x.present ? "just sits there" : "silently disappears"}.`, measured: `${s === "error" ? "500 from" : "Network failure on"} ${reqs}: no new error text, alert or retry anywhere on the page.`, fix: `Catch the failure into an \`error\` state and render it in the ${noun(c)}'s place: what happened, and a Retry button that runs the request again${ui.alert ? `; use ${uiName(ui, "alert")}` : ""}. Never fall through to the empty state.`, copy: copyFor(c, run.requests, "error"), crop: crop(c, s) });
1213
+ else if (x.errorMsg && !x.retry && ["list", "table", "cards", "grid", "text", "number"].includes(c.kind)) add({ comp: c.id, state: s, check: "error-no-retry", severity: "low", what: `The error ("${x.errorMsg.slice(0, 50)}") has no way to try again.`, measured: "No Retry / Try again button near the message.", fix: "Add a Try again button that re-runs the request.", copy: "", crop: crop(c, s) });
1214
+ }
1215
+ const h = R.hang?.comps[c.id];
1216
+ const missing = out.find((x) => x.comp === c.id && x.check === "missing-loading");
1217
+ if (d && h && !h.loadingCue && !h.errorMsg && missing && !missing.states.includes("hang")) missing.states.push("hang");
1218
+ const saysSlow = h && [...(h.newTexts ?? []), ...(h.inPlace ?? [])].some((t) => /taking longer|still loading|slow|hang on|almost there/i.test(t));
1219
+ if (d && h && h.loadingCue && !h.errorMsg && !saysSlow) add({ comp: c.id, state: "hang", check: "no-timeout", severity: "low", what: `When the request never answers, 8s in the ${noun(c)} still shows only its loading cue: nothing says it's slow, and there's no way out.`, measured: `Data held with no answer; at 8s: ${h.loadingCue}, no "taking longer" text, no error.`, fix: "Give the request a timeout (AbortSignal.timeout(10_000)) and show the error state with Try again when it fires; after ~3s say it's taking longer than usual.", copy: "\"This is taking longer than usual…\" then the error state.", crop: crop(c, "hang") });
1220
+
1221
+ // Long text, 320 wide: new overflow, clipping and spills (not already there as it is).
1222
+ const baseIssues = new Set((baseM.comps[c.id]?.overflow ?? []).map((o) => `${o.kind}|${o.sel}`));
1223
+ for (const s of ["long", "narrow"]) {
1224
+ const x = R[s]?.comps[c.id];
1225
+ if (!x || crashed.has(s)) continue;
1226
+ const issues = x.overflow.filter((o) => !baseIssues.has(`${o.kind}|${o.sel}`));
1227
+ if (!issues.length) continue;
1228
+ const worst = issues.find((o) => o.kind === "spill" || o.kind === "outside") ?? issues.find((o) => o.kind === "clip" || o.kind === "clip-y") ?? issues[0];
1229
+ const how = { spill: "spills out of its box", outside: `runs ${worst.px}px outside the ${noun(c)}`, clip: "is cut off with no ellipsis", "clip-y": "is cut off at the bottom", ellipsis: "is truncated with no way to read the rest (no title or tooltip)" }[worst.kind];
1230
+ // Copy written in the code only gets longer when someone edits or translates it: say so, and rank it lower.
1231
+ const hypothetical = s === "long" && !d;
1232
+ const sev = hypothetical ? "low" : issues.some((o) => o.kind !== "ellipsis") ? "medium" : "low";
1233
+ add({ comp: c.id, state: s, check: worst.kind === "ellipsis" ? "truncated-no-title" : "text-overflow", severity: sev, what: `${s === "long" ? (hypothetical ? "If this copy grows (new content, translation), long text" : "Long text") : "At 320px, text"} ${how}: "${worst.text.slice(0, 50)}".`, measured: issues.slice(0, 3).map((o) => `${o.kind} ${o.px}px at ${o.sel.split(" > ").slice(-2).join(" > ")}`).join("; "), fix: worst.kind === "ellipsis" ? "Keep the ellipsis but expose the full text: a title attribute or tooltip, or let it wrap to two lines (line-clamp: 2)." : "Let text wrap inside its box: `min-width: 0` on flex and grid children, `overflow-wrap: anywhere` for names, emails and URLs, and truncate with an ellipsis plus a title only where one line is a must.", copy: "", crop: crop(c, s) });
1234
+ }
1235
+ // 50 items: layout breaks, and a page that grows without end.
1236
+ const m = crashed.has("many") ? null : R.many?.comps[c.id];
1237
+ if (m && c.sig && run.fetched && driven.has(c.id) && R.many.pageHeight - baseM.pageHeight > 4 * 900 && !m.paging) add({ comp: c.id, state: "many", check: "no-pagination", severity: "low", what: `At 50 records the ${noun(c)} pushes the page ${Math.round((R.many.pageHeight - baseM.pageHeight) / 100) * 100}px longer, with no pagination, "Show more" or scroll area.`, measured: `Page height ${baseM.pageHeight}px → ${R.many.pageHeight}px.`, fix: "Paginate (or Show more after ~20), or put the list in its own scroll area with a sticky header; virtualize past a few hundred rows.", copy: "", crop: crop(c, "many") });
1238
+ const t = R.many500?.comps[c.id];
1239
+ if (t?.ms > SLOW_500 || t?.timedOut) add({ comp: c.id, state: "many", check: "slow-500", severity: "medium", what: t.timedOut ? `500 records don't finish rendering in ${t.timedOut / 1000}s.` : `500 records take ${(t.ms / 1000).toFixed(1)}s to render.`, measured: t.timedOut ? `${t.items} items rendered ${t.timedOut / 1000}s after the response.` : `${t.items} items rendered ${t.ms}ms after the response${t.tries ? ` (the fastest of ${t.tries} tries)` : ""}.`, fix: "Virtualize the list (render only the rows on screen) or paginate on the server.", copy: "", crop: null });
1240
+ // One item: a lone card stretched across the row.
1241
+ const o = R.one?.comps[c.id];
1242
+ if (o?.itemBox && baseM.comps[c.id]?.itemBox && ["cards", "grid"].includes(c.kind) && o.itemBox.width > baseM.comps[c.id].itemBox.width * 1.6) add({ comp: c.id, state: "one", check: "one-stretches", severity: "low", what: `With one item, its card stretches to ${o.itemBox.width}px (from ${baseM.comps[c.id].itemBox.width}px).`, measured: `Item width ${baseM.comps[c.id].itemBox.width}px → ${o.itemBox.width}px.`, fix: "Give grid items a fixed track (`repeat(auto-fill, minmax(280px, 1fr))` keeps empty tracks) or a max-width, so one item keeps its size.", copy: "", crop: crop(c, "one") });
1243
+ // Images that fail.
1244
+ const im = crashed.has("images") ? null : R.images?.comps[c.id];
1245
+ // Avatars (small, round, next to a person's name) fall back to initials; any other picture to a neutral tile.
1246
+ const avatars = im?.broken?.length && im.broken.every((b) => b.avatar);
1247
+ const noAlt = im?.broken?.some((b) => !b.alt);
1248
+ if (im?.broken?.length) add({ comp: c.id, state: "images", check: "broken-image", severity: "medium", what: `${im.broken.length} image(s) show the browser's broken-image icon when they fail${avatars ? " (avatars)" : ""}.`, measured: im.broken.slice(0, 3).map((b) => `${b.src}${b.alt ? ` alt "${b.alt}"` : " no alt"}`).join("; "), fix: avatars ? "Fall back on error: onError swaps in initials or a generated avatar seeded by the name, on a neutral background the same size." : `Handle onError: hide the img and show a neutral placeholder the same size (a quiet tile${noAlt ? "; give the image alt text that says what it shows, and use it as the placeholder's label" : " labelled with the alt text"}), so the layout doesn't jump.`, copy: "", crop: crop(c, "images") });
1249
+ }
1250
+
1251
+ // The page as a whole.
1252
+ const plural = (s) => (crashed.has(s) ? [] : R[s]?.plurals ?? []);
1253
+ for (const s of ["one", "empty"]) for (const p of plural(s)) add({ comp: "page", state: s, check: "plural", severity: "low", what: `"${p}": the count isn't pluralised.`, measured: `Seen with ${s === "one" ? "one record" : "no records"}.`, fix: "Pluralise with Intl.PluralRules or a helper: \"1 member\", \"2 members\", and \"No members\" for zero.", copy: "", crop: null });
1254
+ const zeros = (R.loading?.newTexts ?? []).filter((t) => /(^|[^\d.,])0\s+[a-z]/i.test(t) && t.length < 80);
1255
+ if (zeros.length) add({ comp: "page", state: "loading", check: "zero-while-loading", severity: "medium", what: `While the data loads, the screen says "${zeros[0]}": a count of zero the data never said.`, measured: `1.2s into a ${LOAD_HOLD / 1000}s load; as it is, that line reads a real count.`, fix: "Render counts only once the data is in: a skeleton the width of the number while loading, never a default 0.", copy: "", crop: null });
1256
+ for (const s of ["empty", "long", "one", "many"]) for (const lk of R[s]?.leaks?.filter((x) => !(baseM.leaks ?? []).some((b) => b.text === x.text)) ?? []) add({ comp: "page", state: s, check: "leak", severity: "high", what: `The screen shows "${lk.text}" to the reader.`, measured: `${STATES[s]}: ${lk.sel.split(" > ").slice(-2).join(" > ")}.`, fix: "Guard the data path: a fallback for missing fields (`?? \"—\"`), and never render a raw undefined, null or NaN.", copy: "", crop: null });
1257
+ if (R.narrow && R.narrow.overflowX > 1) add({ comp: "page", state: "narrow", check: "page-sideways-320", affects: { narrow: sideways("narrow") }, severity: "high", what: `At 320px the page scrolls sideways by ${R.narrow.overflowX}px.`, measured: `scrollWidth exceeds the viewport by ${R.narrow.overflowX}px.`, fix: "Find the element wider than the screen (fixed widths, long words, wide tables) and let it shrink or scroll in its own box.", copy: "", crop: path.join(path.dirname(run.pageShot), "page-narrow.png") });
1258
+ if (R.zoom && R.zoom.overflowX > 1) add({ comp: "page", state: "zoom", check: "page-sideways-zoom", affects: { zoom: sideways("zoom") }, severity: "medium", what: `At 200% zoom the page scrolls sideways by ${R.zoom.overflowX}px.`, measured: "720×450 CSS px viewport.", fix: "Let the layout reflow at 720px: no fixed widths wider than the viewport.", copy: "", crop: path.join(path.dirname(run.pageShot), "page-zoom.png") });
1259
+ const fi = R.zoom?.focusIssues ?? [];
1260
+ const says = (f) => (f.obscured ? `${f.what} is hidden under ${f.obscured}` : f.hidden ? `${f.what} can't be seen` : `${f.what} is off screen`);
1261
+ if (fi.length) add({ comp: "page", state: "zoom", check: "focus-lost", severity: "medium", what: `Keyboard focus lands where it can't be seen (${fi.length} stop${fi.length > 1 ? "s" : ""}): ${fi.slice(0, 3).map(says).join("; ")}.`, measured: `Tab pass at 200% zoom through ${R.zoom.stops} stops: ${fi.slice(0, 4).map((f) => f.sel.split(" > ").slice(-2).join(" > ")).join("; ")}.`, fix: [fi.some((f) => f.obscured) && "Add `scroll-padding-top` (the sticky bar's height) to html so focused elements scroll clear of it.", fi.some((f) => !f.obscured) && "Take hidden or off-screen copies out of the tab order (inert, hidden, or tabindex=-1 on decorative duplicates), or show them on focus."].filter(Boolean).join(" "), copy: "", crop: fi[0].file });
1262
+
1263
+ // Forms.
1264
+ for (const f of components.filter((c) => c.kind === "form" && !c.sameAs)) {
1265
+ const sub = R[`${f.id}:submitting`];
1266
+ const err = R[`${f.id}:submit-error`];
1267
+ const off = R[`${f.id}:offline`];
1268
+ const emp = R[`${f.id}:empty-submit`];
1269
+ const twins = components.filter((c) => c.sameAs === f.id).map((c) => c.id);
1270
+ const also = twins.length ? ` (the same form as ${twins.join(", ")})` : "";
1271
+ if (sub && !sub.sent && sub.clicked) {
1272
+ // Why nothing went out: the form's own validation refused every test value palate tried, or it got
1273
+ // through and the page handled the send itself (no request to fail or slow down).
1274
+ const pr = f.probe;
1275
+ const tried = (pr?.blocked ?? []).map((b) => `${b.label} (${b.tried.slice(0, 4).map((v) => `"${v}"`).join(", ")})`).join("; ");
1276
+ const what = pr && !pr.passed
1277
+ ? `palate couldn't get past the form's own validation${also}: ${tried ? `it tried ${tried}` : `${pr.rounds} round(s) of test values`}${pr.message ? ` and the form said "${pr.message.slice(0, 80)}"` : ""}. Its pending, error and offline states went untested.`
1278
+ : pr?.passed && !pr.sent
1279
+ ? `Test values passed the form's validation but no request went out${also}: the send happens in the page (or isn't wired to a server yet), so palate couldn't slow it down or fail it. Its pending, error and offline states went untested.`
1280
+ : `With test values the form sent nothing${also}; palate couldn't test its pending, error and offline states.`;
1281
+ const fix = pr && !pr.passed ? "If the format is that strict, say it in the label or placeholder (palate tries the placeholder first), and check the message is visible and clear. Then test these states by hand, or run again." : pr?.passed && !pr.sent ? "Once the form posts to a real endpoint, run palate states again; until then test these states by hand." : "If validation stopped it, check the message is visible and clear. Otherwise test these states by hand.";
1282
+ add({ comp: f.id, state: "submitting", check: "form-sent-nothing", severity: "low", what, measured: `Fields: ${f.fields.map((x) => x.type).join(", ")}; new text: ${sub.after.newTexts.slice(0, 2).join(" · ") || "none"}${pr ? `; ${pr.rounds} round(s) of test values` : ""}.`, fix, copy: "", crop: run.cells[f.id]?.submitting });
1283
+ }
1284
+ if (sub?.sent >= 2) add({ comp: f.id, state: "submitting", check: "double-submit", severity: "high", what: `Pressing "${f.submitText}" twice sends the form twice${also}.`, measured: `${sub.sent} requests from two presses 30ms apart.`, fix: "Ignore submits while one is pending (a ref set before the first await: `if (pending.current) return`; state alone is too slow for a 30ms race), and set aria-disabled on the button while it runs (keep it focusable).", copy: "", crop: run.cells[f.id]?.submitting });
1285
+ if (sub?.outcome?.successes?.length >= 2) add({ comp: f.id, state: "submitting", check: "double-success", severity: "medium", what: `Pressing "${f.submitText}" twice shows ${sub.outcome.successes.length} success messages${also}.`, measured: `After two presses 30ms apart: ${sub.outcome.successes.slice(0, 2).map((t) => `"${t.slice(0, 50)}"`).join(", ")}.`, fix: "One submit, one outcome: ignore the second press while the first is pending, and replace the status message rather than adding one per response.", copy: "", crop: run.cells[f.id]?.submitted });
1286
+ // The transition: pending at first, idle again before the answer came, so a third press would send again.
1287
+ const drop = sub?.samples?.[0]?.cue ? sub.samples.find((x) => !x.cue) : null;
1288
+ if (sub?.sent >= 1 && drop) add({ comp: f.id, state: "submitting", check: "pending-drops", severity: "medium", what: `The "${f.submitText}" button goes back to idle ${(drop.at / 1000).toFixed(1)}s into a 3s request${also}: it looks done and can be pressed again before the outcome is known.`, measured: `Looked at 0.1s, 1.2s and 2.5s: ${sub.samples.map((x) => `${(x.at / 1000).toFixed(1)}s ${x.cue ? "pending" : "idle"}`).join(", ")}.`, fix: "Keep the button pending (aria-disabled, spinner or -ing label) until the request settles, success or failure; tie it to the request, not to a timer.", copy: "", crop: run.cells[f.id]?.submitting });
1289
+ if (sub?.sent >= 1) {
1290
+ const p = sub.pending;
1291
+ const cue = sub.samples?.some((x) => x.cue) || p.submit && (p.submit.disabled || p.submit.ariaDisabled || p.submit.busy || p.submit.cue || (p.submit.text && p.submit.text !== f.submitText)) ? true : Boolean(p.comps[f.id]?.loadingCue);
1292
+ if (!cue) add({ comp: f.id, state: "submitting", check: "no-pending", severity: "medium", what: `For the 3s the request runs, nothing shows the form is sending${also}.`, measured: `The "${f.submitText}" button stayed the same (not disabled, no aria-busy, no spinner, same label).`, fix: `Show the pending state on the button: a spinner or "${f.submitText.replace(/^(\w+)/, (w) => (/e$/.test(w) ? `${w.slice(0, -1)}ing` : `${w}ing`))}…", aria-disabled while it runs${ui.spinner ? `; use ${uiName(ui, "spinner")}` : ""}.`, copy: "Name what is happening: \"Sending…\", \"Saving…\".", crop: run.cells[f.id]?.submitting });
1293
+ if (!sub.focus || sub.focus.body) add({ comp: f.id, state: "submitting", check: "focus-lost", severity: "medium", what: `After the form is sent, keyboard focus is lost (it falls back to the page)${also}.`, measured: "document.activeElement is <body> after the response.", fix: "Move focus to the result: the success message (tabIndex=-1, focus()) or back to the first field.", copy: "", crop: run.cells[f.id]?.submitted });
1294
+ }
1295
+ if (err?.sent >= 1) {
1296
+ const a = err.after;
1297
+ const msg = a.comps[f.id]?.errorMsg;
1298
+ if (!msg && !a.errorTexts.length) add({ comp: f.id, state: "submit-error", check: "error-swallowed", severity: "high", what: `When sending fails, the form says nothing${a.comps[f.id]?.newTexts.length ? ` (it shows "${a.comps[f.id].newTexts[0].slice(0, 40)}")` : ""}${also}.`, measured: "The request answered 500; no error text or alert appeared.", fix: `Check \`res.ok\`, keep the form as it was, and show the error next to the button (role=alert)${ui.alert ? `; use ${uiName(ui, "alert")}` : ""}.`, copy: `Say what happened and what to do: "${formCopy(f).failed}"`, crop: run.cells[f.id]?.["submit-error"] });
1299
+ const lost = err.filled.map((v, i) => v && !a.fields[i] ? f.fields[i].label || f.fields[i].type : null).filter(Boolean);
1300
+ if (lost.length) add({ comp: f.id, state: "submit-error", check: "clears-on-error", severity: "medium", what: `When sending fails, the form clears what the user typed (${lost.join(", ")}).`, measured: "Field values before: filled; after the 500: empty.", fix: "Clear the fields only after a success.", copy: "", crop: run.cells[f.id]?.["submit-error"] });
1301
+ }
1302
+ if (off && off.clicked) {
1303
+ const a = off.after;
1304
+ if (!a.comps[f.id]?.errorMsg && !a.errorTexts.length) add({ comp: f.id, state: "offline", check: "offline-silent", severity: "medium", what: `Offline, sending the form does nothing visible${also}.`, measured: "Browser offline, the button pressed: no message after 2s.", fix: "Catch the network error and say so; keep what the user typed so they can send it again.", copy: "\"You're offline. Check your connection and try again.\"", crop: run.cells[f.id]?.offline });
1305
+ }
1306
+ if (emp && emp.clicked) {
1307
+ if (emp.sent) add({ comp: f.id, state: "empty-submit", check: "empty-sent", severity: "medium", what: `The empty form is sent to the server${also}.`, measured: `${emp.sent} request(s) with every field empty.`, fix: "Validate before sending: required fields stop the submit and say which field and why.", copy: `Name the field and the fix: "${formCopy(f).enter}"`, crop: run.cells[f.id]?.["empty-submit"] });
1308
+ else if (!emp.after.comps[f.id]?.newTexts.length && f.noValidate) add({ comp: f.id, state: "empty-submit", check: "empty-silent", severity: "low", what: "Sending the empty form does nothing visible.", measured: "No request, no message.", fix: "Say what's missing next to the field (aria-invalid, aria-describedby).", copy: `"${formCopy(f).enter}"`, crop: run.cells[f.id]?.["empty-submit"] });
1309
+ }
1310
+ // Saved, answer lost, retried.
1311
+ const lr = R[`${f.id}:lost-retry`];
1312
+ if (lr?.clicked && lr.writes.length >= 1) {
1313
+ const n = lr.writes.length;
1314
+ const crop0 = run.cells[f.id]?.["lost-retry"];
1315
+ const seen = `First send reached the server (palate stood in) and its answer was dropped${lr.failedMsg ? `; the UI said "${lr.failedMsg.slice(0, 60)}"` : ""}; retried by ${lr.how ?? "nothing (no way to retry)"}`;
1316
+ const fixKey = "Generate an idempotency key once per submit attempt (crypto.randomUUID()), send it as an Idempotency-Key header, and reuse the same key when the user retries; the server stores the key with the result and answers a repeat with the first result. Or, before retrying, check whether the record already exists. Keep the button pending until the outcome is known.";
1317
+ if (n >= 2 && !lr.idempotent && !lr.checked) add({ comp: f.id, state: "lost-retry", check: "retry-duplicate", severity: "high", what: `Retry after a lost response sends a second write: the record would be created twice${also}.`, measured: `${seen}. ${n} writes reached the server (${[...new Set(lr.writes.map((w) => new URL(w.url).pathname))].join(", ")}), with no Idempotency-Key or request-id header the same on both and no client-made id repeating in the body${lr.writes[0].body === lr.writes[1].body ? " (the two bodies are identical)" : ""}.`, fix: fixKey, copy: "", crop: crop0 });
1318
+ if (lr.end?.records >= 2) add({ comp: f.id, state: "lost-retry", check: "retry-two-records", severity: "high", what: `After a lost response and a retry, the screen shows the record twice${also}.`, measured: `${seen}; ${lr.end.records} elements show what was typed.`, fix: `${fixKey} Add the record to the screen once, keyed by its id.`, copy: "", crop: crop0 });
1319
+ if (lr.end?.successes?.length >= 2) add({ comp: f.id, state: "lost-retry", check: "retry-two-successes", severity: "medium", what: `After a lost response and a retry, the screen shows two success messages${also}.`, measured: `${seen}; ${lr.end.successes.slice(0, 2).map((t) => `"${t.slice(0, 50)}"`).join(", ")}.`, fix: "One outcome per submit: replace the status message instead of adding one per response.", copy: "", crop: crop0 });
1320
+ }
1321
+ // A failed submit, keyboard only: focus, message, announcement, values, on screen, fixed.
1322
+ for (const key of ["recover", "recover-narrow"]) {
1323
+ const rr = R[`${f.id}:${key}`];
1324
+ for (const x of recoveryFindings(f, rr, key)) add({ ...x, what: x.what.replace(/\.$/, `${also}.`), crop: x.check === "error-offscreen" ? rr.screen ?? run.cells[f.id]?.[key] : run.cells[f.id]?.[key] ?? rr.screen });
1325
+ }
1326
+ }
1327
+ // Buttons outside forms.
1328
+ for (const a of R.actions ?? []) {
1329
+ if (a.sent >= 2) add({ comp: "page", state: "pressed", check: "double-submit", severity: "high", what: `Pressing "${a.text}" twice sends ${a.sent} requests.`, measured: "Two presses 80ms apart, every request held 2.5s.", fix: "Ignore presses while the first is pending and show it on the button (aria-disabled, a spinner).", copy: "", crop: a.file });
1330
+ else if (a.submit && !(a.submit.disabled || a.submit.ariaDisabled || a.submit.busy || a.submit.cue)) add({ comp: "page", state: "pressed", check: "no-pending", severity: "medium", what: `"${a.text}" sends a request but shows nothing while it runs.`, measured: "Same button 300ms after the press.", fix: "Show a pending state on the button while its request runs.", copy: "", crop: a.file });
1331
+ }
1332
+ // Findings about the page as a whole show that state's first screen.
1333
+ for (const f of out) {
1334
+ const fold = path.join(path.dirname(run.pageShot), `page-${f.state}.png`);
1335
+ if (!f.crop && fs.existsSync(fold)) f.crop = fold;
1336
+ }
1337
+ const rank = (f) => SEV.indexOf(f.severity) * 100 + Object.keys(STATES).indexOf(f.state);
1338
+ return out.sort((a, b) => rank(a) - rank(b)).map((f, i) => ({ id: `S${i + 1}`, ...f, componentLabel: f.comp === "page" ? "the page" : label(components.find((c) => c.id === f.comp)), where: f.comp === "page" ? null : loc(components.find((c) => c.id === f.comp)) }));
1339
+ }
1340
+
1341
+ // What each keyboard recovery run measured, compact, for findings.json and the brief.
1342
+ const RECOVERY_CHECKS = { "focus-not-on-error": "focus moves to the field", "error-not-tied": "aria-invalid + message tied", "error-not-announced": "announced", "values-lost": "values kept", "error-offscreen": "on screen", "error-sticks": "clears when fixed" };
1343
+ function recoveryOf(R) {
1344
+ const out = {};
1345
+ for (const [k, r] of Object.entries(R)) {
1346
+ const [id, key] = k.split(":");
1347
+ if (!/^recover/.test(key ?? "") || !r) continue;
1348
+ const r1 = r.r1;
1349
+ (out[id] ??= {})[key] = r.skipped ? { skipped: r.skipped } : { plan: r.plan, tabs: r.tabs, byScript: r.byScript, how: r.how, accepted: Boolean(r.accepted), sent: r.sent1, focus: r1?.active?.what ?? null, message: r1?.message?.text ?? null, nativeBlocked: r1?.nativeBlocked, fields: r1?.fields?.map((x, i) => x && { ariaInvalid: x.ariaInvalid, described: x.described.map((d) => `#${d.id}`), kept: !r.values?.[i] || x.value === r.values[i] }), live: r1?.live ?? [], fix: r.fix ? { how: r.fix.how, byScript: r.fix.byScript, sent: r.fix.sent, stillInvalid: Boolean(r.fix.r2?.fields?.[r.plan.target]?.ariaInvalid), stillShown: Boolean(r.fix.r2?.stillShown) } : null };
1350
+ }
1351
+ return out;
1352
+ }
1353
+
1354
+ // The facts the user's rules read (null: not measurable on this screen).
1355
+ function factsOf(run, findings, kind) {
1356
+ const n = (check) => findings.filter((f) => [].concat(check).includes(f.check)).length;
1357
+ const lists = run.components.some((c) => c.sig && c.kind !== "form");
1358
+ const forms = run.components.some((c) => c.kind === "form") || (run.results.actions ?? []).length > 0;
1359
+ const fetched = kind === "fetched";
1360
+ return {
1361
+ listsWithoutEmptyState: fetched && lists ? n("missing-empty") : null,
1362
+ missingLoadingStates: fetched ? n(["missing-loading", "empty-while-loading"]) : null,
1363
+ errorsSwallowed: fetched || forms ? n(["error-swallowed", "crash"]) : null,
1364
+ doubleSubmits: forms ? n(["double-submit", "retry-duplicate"]) : null,
1365
+ brokenImages: run.baseM.images ? n("broken-image") : null,
1366
+ textOverflows: n(["text-overflow", "page-sideways"]),
1367
+ };
1368
+ }
1369
+
1370
+ function cannotOf(kind, src, run) {
1371
+ const out = [];
1372
+ if (kind === "static") out.push(`Empty, loading and error: the data on this screen is written in the code${src.staticArrays.length ? ` (${src.staticArrays.slice(0, 3).map((s) => `\`${s.name}\` in ${s.file}:${s.line}`).join(", ")})` : ""}, not fetched, so it can't be empty, slow or fail until it comes from somewhere. Long text, many items and one item were simulated in the DOM instead.`);
1373
+ if (kind === "server") out.push(`Data fetched on the server (${src.server.map((s) => `${s.file}:${s.line}`).join(", ")}) never reaches the browser, so palate can't empty, delay or fail it. ${src.conventions.loading ? `There is a ${src.conventions.loading}.` : "Add loading.tsx next to the route (Next) for the loading state,"} ${src.conventions.error ? `and ${src.conventions.error}.` : "and error.tsx for the error state; test empty with an empty fixture in your data layer."}`);
1374
+ if (kind === "fetched-later") out.push(`The source fetches (${src.fetches.slice(0, 2).map((f) => `${f.file}:${f.line}`).join(", ")}), but nothing was requested during load: probably on an interaction palate didn't make. Those states weren't rendered.`);
1375
+ if (!run.components.some((c) => c.kind === "form") && !(run.results.actions ?? []).length) out.push("Slow interaction, double submit and offline: nothing on this screen sends a request when used.");
1376
+ out.push(run.baseM.images ? "Missing images: <img> elements were failed and checked; CSS background and mask images (logos painted through a mask) just vanish and aren't checked." : "Missing images: this screen has no <img> elements; CSS background and mask images aren't checked.");
1377
+ if (kind !== "static") out.push("WebSockets, server-sent events and Server Actions aren't mocked.");
1378
+ out.push("DOM transforms change the page after React rendered it; a part that re-renders by itself (an autoplaying demo) may undo them.");
1379
+ return out;
1380
+ }
1381
+
1382
+ // A part fails in a state when a finding names it there, or a page-wide one (a crash, sideways scroll) took it down.
1383
+ export const failsIn = (findings, id, s) => findings.some((f) => (f.comp === id && f.states.includes(s)) || (f.affects?.[s] ?? []).includes(id));
1384
+
1385
+ // ---- the contact sheet: one row per part, one column per state it has
1386
+ async function sheetOf(browser, run, findings, session) {
1387
+ const marks = (c, s) => (failsIn(findings, c.id, s) ? " ✗" : " ✓");
1388
+ const rows = run.components.map((c) => {
1389
+ const states = c.kind === "form" ? (c.sameAs ? ["default"] : ["default", "submitting", "submitted", "submit-error", "offline", "empty-submit", "lost-retry", "recover", "recover-narrow", "narrow"]) : ["default", "empty", "loading", "error", "network", "hang", "long", "many", "one", "images", "narrow"];
1390
+ const cells = states
1391
+ .filter((s) => s === "default" || run.ran[s === "submitted" ? "submitting" : s] || run.cells[c.id]?.[s])
1392
+ .map((s) => ({ caption: `${s === "submitted" ? "after sending" : STATES[s]}${s === "default" || s === "submitted" ? "" : run.cells[c.id]?.[s] ? marks(c, s) : ""}`, file: run.cells[c.id]?.[s] ?? null, note: c.kind === "form" || s === "default" ? "not shot" : "not driven by data here" }));
1393
+ const req = c.kind !== "form" && isDriven(c, run.baseM, run.results.empty) ? " · from the fetched data" : "";
1394
+ return { label: `${label(c)}${c.count ? ` · ${c.count} items` : ""}${c.sameAs ? ` · same form as ${c.sameAs}, tested there` : ""}${req}`, cells };
1395
+ });
1396
+ const pageCells = [
1397
+ { caption: "1440", file: run.pageShot },
1398
+ ...["narrow", "zoom"].map((s) => ({ caption: `${STATES[s]}${findings.some((f) => f.comp === "page" && f.states.includes(s)) ? " ✗" : " ✓"}`, file: fs.existsSync(path.join(session, `page-${s}.png`)) ? path.join(session, `page-${s}.png`) : null })),
1399
+ ...findings.filter((f) => f.comp === "page" && f.crop && fs.existsSync(f.crop) && !/page-(narrow|zoom)\.png$/.test(f.crop)).slice(0, 4).map((f) => ({ caption: `${f.id} ${STATES[f.state]} ✗`, file: f.crop })),
1400
+ ];
1401
+ rows.push({ label: "The page", cells: pageCells.filter((c) => c.file) });
1402
+ const out = path.join(session, "sheet.png");
1403
+ await contactSheet(browser, rows, out, { cell: 300, maxCell: 300 });
1404
+ return out;
1405
+ }
1406
+
1407
+ // ---- the brief
1408
+ function briefOf(dir, route, rec, session) {
1409
+ const tags = routeTags(dir, route);
1410
+ const all = rulesBlock(tags, { failures: false, route, project: projectOf(dir) });
1411
+ // Only the user's state rules belong here (the rest apply to the design, and fix and shoot carry them).
1412
+ const stateRules = all.rules.filter((r) => STATE_METRICS.has(r.check?.metric));
1413
+ const results = checkRules(stateRules, rec.facts ? { ...rec.facts, statesRun: path.basename(session) } : null);
1414
+ const mark = (r) => results.find((x) => x.id === r.id)?.status;
1415
+ const mine = {
1416
+ line: all.line,
1417
+ lines: stateRules.length ? ["## Your rules for states (must pass)", ...stateRules.map((r) => `- ${mark(r) === "fail" ? "✗ FAILS NOW: " : mark(r) === "pass" ? "✓ " : ""}**${r.id}** ${r.rule}`), ""] : [`No state rules yet. To have palate enforce one on every shoot: palate rule add "every list has an empty state" (also loading, errors, double submit, broken images, long text).`, ""],
1418
+ };
1419
+ const { findings, data, source: src } = rec;
1420
+ const reqs = data.requests.map((r) => `${r.method} ${new URL(r.url).pathname} (${r.status})`).filter((v, i, a) => a.indexOf(v) === i);
1421
+ const dataLine = {
1422
+ fetched: `fetched in the browser: ${reqs.join(", ")}. palate rewrote those answers for each state.`,
1423
+ static: `written in the code${src.staticArrays.length ? ` (${src.staticArrays.slice(0, 4).map((s) => `\`${s.name}\` in ${s.file}`).join(", ")})` : ""}. Nothing is fetched while the page loads, so it can't be empty, loading or failing yet; palate simulated long text, many items and one item in the DOM.`,
1424
+ server: `fetched on the server (${src.server.map((s) => `${s.file}:${s.line}`).join(", ")}); the browser never sees those requests.`,
1425
+ "fetched-later": `fetched by the source (${src.fetches.slice(0, 2).map((f) => `${f.file}:${f.line}`).join(", ")}), but not while the page loads.`,
1426
+ }[data.kind];
1427
+ const forms = rec.components.filter((c) => c.kind === "form");
1428
+ const ok = summaryOk(rec);
1429
+ const byComp = rec.components.map((c) => `- **${c.id}** ${c.kind}${c.heading ? ` "${c.heading}"` : ""}${c.count ? `, ${c.count} items` : ""}${c.where?.unsure ? " · where: not sure" : c.where ? ` · ${c.where.file}:${c.where.line}` : ""}${c.sameAs ? ` · same form as ${c.sameAs}` : ""}`);
1430
+ const lines = [
1431
+ mine.line,
1432
+ "",
1433
+ `# States: ${route}`,
1434
+ "",
1435
+ "AI gets a screen 80% of the way; this is the rest: every state of every part, rendered and measured. Build the missing states below, most important first, with the project's own components. Keep the design and its idea; change only what each finding names.",
1436
+ "",
1437
+ ...mine.lines,
1438
+ `Data on this screen: ${dataLine}`,
1439
+ `Source read: ${src.entry ?? "no route file found (routing in code)"}${src.files.length > 1 ? ` and ${src.files.length - 1} file(s) it imports` : ""}; ${src.loading} line(s) handle loading, ${src.error} errors, ${src.empty} empty arrays.`,
1440
+ `Contact sheet: ${slash(rec.sheet ?? path.join(session, "sheet.png"))} (one row per part, one column per state; ✗ marks a finding). Crops: ${slash(path.join(session, "crops"))}.`,
1441
+ "",
1442
+ "## The parts palate found",
1443
+ ...byComp,
1444
+ ...(rec.actions?.length ? [`- ${rec.actions.length} button(s) outside forms, pressed twice to see which send requests.`] : []),
1445
+ "",
1446
+ findings.length ? `## Build these states (${findings.length}, most important first)` : "## No missing states found",
1447
+ ...(findings.length ? [] : ["Every state palate could render held up. Look at the contact sheet anyway: measurements catch what's missing, not whether it's good."]),
1448
+ ...findings.map((f, i) =>
1449
+ [
1450
+ `### ${i + 1}. ${f.id} [${f.severity}] ${f.componentLabel} · ${f.states.map((s) => STATES[s]).join(", ")}`,
1451
+ `- What: ${f.what}`,
1452
+ `- Measured: ${f.measured}`,
1453
+ f.where ? `- Where: ${f.where}` : "",
1454
+ `- Build: ${f.fix}`,
1455
+ f.copy ? `- Copy: ${f.copy}` : "",
1456
+ f.crop ? `- Crop: ${slash(f.crop)}` : "",
1457
+ ]
1458
+ .filter(Boolean)
1459
+ .join("\n"),
1460
+ ),
1461
+ ok.length ? "\n## Measured and fine" : "",
1462
+ ...ok,
1463
+ "",
1464
+ "## What palate could not simulate here",
1465
+ ...rec.cannot.map((c) => `- ${c}`),
1466
+ "",
1467
+ "## How",
1468
+ "- One finding at a time, in this order. Reuse the project's components" + (Object.keys(src.ui).length ? ` (${Object.entries(src.ui).map(([k, v]) => `${k}: ${v}`).join(", ")})` : "") + " and tokens; don't install packages without asking.",
1469
+ "- Empty states say what is empty and what to do next, with the action as a button. Errors say what happened and what to do, with Try again; never fall through to the empty state. Loading is a skeleton shaped like the content (aria-busy on the region), after ~300ms. Pending buttons keep their size, show a spinner or an -ing label, ignore a second press, and stay pending until the outcome is known. A write the user may retry carries an idempotency key made once per submit and reused on the retry. A failed submit moves focus to the first invalid field (or an error summary linking to it), marks it aria-invalid with aria-describedby pointing at a visible message in a live region, keeps every value, and clears when fixed.",
1470
+ "- Real copy in the product's voice; no codes, no blame, no exclamation marks.",
1471
+ "",
1472
+ "## Verify",
1473
+ `When all are done: \`palate states ${route} --verify\`. It renders every state again and puts each finding before | after, measured: fixed or still failing. Then the project's typecheck. Don't commit.`,
1474
+ ];
1475
+ return lines
1476
+ .filter((l, i, a) => !(l === "" && a[i - 1] === ""))
1477
+ .join("\n")
1478
+ .replace(/\n(#{2,3} )/g, "\n\n$1")
1479
+ .replace(/\n{3,}/g, "\n\n");
1480
+ }
1481
+
1482
+ // What held up, in one line each (so the brief says what was checked, not only what failed).
1483
+ function summaryOk(rec) {
1484
+ const out = [];
1485
+ const bad = (c, s) => failsIn(rec.findings, c, s);
1486
+ for (const c of rec.components) {
1487
+ const states = Object.keys(rec.cells[c.id] ?? {}).filter((s) => s !== "default" && s !== "submitted" && s !== "loading-2" && !bad(c.id, s));
1488
+ if (states.length) out.push(`- ${c.id} ${c.kind}${c.heading ? ` "${c.heading}"` : ""}: ${states.map((s) => STATES[s] ?? s).join(", ")} ✓`);
1489
+ }
1490
+ for (const [id, runs] of Object.entries(rec.recovery ?? {})) {
1491
+ for (const [key, r] of Object.entries(runs)) {
1492
+ if (r.skipped || r.accepted) continue;
1493
+ const ok = Object.entries(RECOVERY_CHECKS).filter(([check]) => !rec.findings.some((f) => f.comp === id && f.check === check && f.states.includes(key))).map(([, what]) => what);
1494
+ if (ok.length) out.push(`- ${id} ${STATES[key]}: ${ok.join(", ")} ✓`);
1495
+ }
1496
+ }
1497
+ if (rec.ran.zoom && !rec.findings.some((f) => f.state === "zoom")) out.push(`- Keyboard at 200% zoom: ${rec.ran.zoom.note.split("; ")[1] ?? "Tab pass"}, focus always visible ✓`);
1498
+ if (rec.ran.narrow && !rec.findings.some((f) => f.comp === "page" && f.state === "narrow")) out.push("- 320 wide: no sideways scroll ✓");
1499
+ return out;
1500
+ }
1501
+
1502
+ // ---- verify: the earlier findings, before | after
1503
+ function matchComp(c, list) {
1504
+ return list.find((x) => x.kind === c.kind && x.selector === c.selector) ?? list.find((x) => x.kind === c.kind && c.heading && x.heading === c.heading) ?? list.find((x) => x.kind === c.kind && c.sig && x.sig === c.sig) ?? list.find((x) => x.kind === c.kind && c.formSig && x.formSig === c.formSig) ?? null;
1505
+ }
1506
+ async function verifyOf(browser, prevDir, now, session) {
1507
+ const prev = readJson(path.join(prevDir, "findings.json"), null);
1508
+ if (!prev) throw new Error(`Could not read ${prevDir}/findings.json`);
1509
+ const rows = [];
1510
+ const lines = [];
1511
+ let fixed = 0;
1512
+ let gone = 0;
1513
+ for (const f of prev.findings) {
1514
+ const pc = prev.components.find((c) => c.id === f.comp);
1515
+ const nc = pc ? matchComp(pc, now.components) : null;
1516
+ const still = now.findings.find((g) => g.check === f.check && (f.comp === "page" ? g.comp === "page" : nc && g.comp === nc.id));
1517
+ // A part palate no longer finds isn't fixed, it's gone (or was never a part): said, not counted.
1518
+ const lost = !still && f.comp !== "page" && !nc;
1519
+ if (lost) gone++;
1520
+ else if (!still) fixed++;
1521
+ const fold = (s) => path.join(session, `page-${s}.png`);
1522
+ const after = f.comp === "page" ? still?.crop ?? f.states.map(fold).find((p) => fs.existsSync(p)) ?? null : nc ? f.states.map((s) => now.cells[nc.id]?.[s]).find(Boolean) ?? null : null;
1523
+ lines.push(`- ${f.id} [${f.severity}] ${f.componentLabel} · ${f.states.map((s) => STATES[s]).join(", ")}: ${still ? `**still failing** (now ${still.id}: ${still.measured})` : lost ? "**part not found now** (not counted as fixed)" : "**fixed**"} · ${f.what}`);
1524
+ rows.push({ label: `${f.id} ${f.componentLabel} · ${f.states.map((s) => STATES[s]).join(", ")} · ${f.check}: ${still ? "STILL FAILING" : lost ? "part not found now" : "fixed"}`, cells: [{ caption: "before", file: f.crop && fs.existsSync(f.crop) ? f.crop : null, note: "no crop" }, { caption: "after", file: after && fs.existsSync(after) ? after : null, note: nc || f.comp === "page" ? "not shot" : "the part is gone" }] });
1525
+ }
1526
+ const fresh = now.findings.filter((g) => !prev.findings.some((f) => f.check === g.check && (g.comp === "page" ? f.comp === "page" : (() => {
1527
+ const pc = prev.components.find((c) => c.id === f.comp);
1528
+ return pc && matchComp(pc, now.components)?.id === g.comp;
1529
+ })())));
1530
+ const sheet = rows.length ? await contactSheet(browser, rows, path.join(session, "before-after.png"), { cell: 520, maxCell: 420 }) : null;
1531
+ const text = [
1532
+ `# Verify the states: ${now.route}`,
1533
+ "",
1534
+ `Before: ${path.basename(prevDir)} (the first run) · after: ${path.basename(session)}. ${fixed} of ${prev.findings.length} finding(s) fixed since the first run, measured${gone ? `; ${gone} belong to parts palate no longer finds` : ""}${fresh.length ? `; ${fresh.length} new since then, listed separately` : ""}.`,
1535
+ sheet ? `Before | after: ${slash(sheet)}. The new contact sheet: ${slash(now.sheet ?? "")}.` : "",
1536
+ "",
1537
+ "## Findings of the first run",
1538
+ ...lines,
1539
+ fresh.length ? `\n## New since the first run (${fresh.length}, not counted above)\n${fresh.map((g) => `- ${g.id} [${g.severity}] ${g.componentLabel} · ${g.states.map((s) => STATES[s]).join(", ")}: ${g.what}${g.crop ? ` Crop: ${slash(g.crop)}` : ""}`).join("\n")}` : "",
1540
+ "",
1541
+ fixed + gone === prev.findings.length && !fresh.length ? "Every state holds up. Open the before | after once to check the new states look right, not only that they exist." : `Fix what is still failing${fresh.length ? " and what is new" : ""}, then run \`palate states ${now.route} --verify\` again.`,
1542
+ ]
1543
+ .filter((l, i, a) => !(l === "" && a[i - 1] === ""))
1544
+ .join("\n");
1545
+ fs.writeFileSync(path.join(session, "verify.md"), text);
1546
+ return { text: `${text}\n\n(Saved to ${slash(path.join(session, "verify.md"))})`, sheet };
1547
+ }