@r1z4x/tezgah 1.9.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (445) hide show
  1. package/CHANGELOG.md +415 -1
  2. package/MANIFEST +404 -144
  3. package/README.de.md +1 -1
  4. package/README.es.md +1 -1
  5. package/README.fr.md +1 -1
  6. package/README.md +5 -4
  7. package/README.tr.md +1 -1
  8. package/bin/codegen +5 -2
  9. package/bin/tezgah-design +288 -1
  10. package/bin/tezgah-docs +1 -1
  11. package/bin/tezgah-doctor +39 -2
  12. package/bin/tezgah-import-ai-research +26 -12
  13. package/bin/tezgah-ocr-review +296 -0
  14. package/bin/tezgah-research +255 -44
  15. package/bin/tezgah-review-quote +185 -0
  16. package/bin/tezgah-setup +1365 -55
  17. package/bin/tezgah-skill +1 -1
  18. package/bin/tezgah-status +201 -3
  19. package/bin/tezgah-task +258 -20
  20. package/commands/adhd.md +2 -2
  21. package/commands/ponytail.md +4 -4
  22. package/docs/architecture.md +2 -2
  23. package/docs/citations-baseline.json +3 -3
  24. package/docs/clarity-baseline.json +11 -5
  25. package/docs/contract.md +91 -43
  26. package/docs/environment.md +5 -2
  27. package/docs/evidence.md +109 -17
  28. package/docs/feature-audit.md +2 -2
  29. package/docs/gate.md +60 -33
  30. package/docs/glossary.md +6 -3
  31. package/docs/hosts.md +313 -17
  32. package/docs/index.json +34 -8
  33. package/docs/judge.md +4 -2
  34. package/docs/layers.md +4 -4
  35. package/docs/models.md +37 -9
  36. package/docs/operations.md +141 -5
  37. package/docs/research.md +815 -8
  38. package/docs/skills.md +137 -13
  39. package/docs/status-line.md +121 -1
  40. package/docs/testing.md +3 -1
  41. package/hooks/hooks.json +2 -2
  42. package/hooks/lang/en.json +21 -0
  43. package/hooks/lang/tr.json +22 -1
  44. package/hooks/projects-stop.py +2 -1
  45. package/hooks/tezgah_agents.py +65 -25
  46. package/hooks/tezgah_apps.py +18 -0
  47. package/hooks/tezgah_attest.py +79 -10
  48. package/hooks/tezgah_components.py +4 -0
  49. package/hooks/tezgah_context.py +207 -55
  50. package/hooks/tezgah_extension.py +337 -0
  51. package/hooks/tezgah_gate.py +267 -79
  52. package/hooks/tezgah_integrity.py +864 -143
  53. package/hooks/tezgah_judge.py +6 -4
  54. package/hooks/tezgah_models.py +59 -19
  55. package/hooks/tezgah_orca.py +1 -1
  56. package/hooks/tezgah_paths.py +145 -8
  57. package/hooks/tezgah_policy.py +128 -57
  58. package/hooks/tezgah_rank.py +9 -6
  59. package/hooks/tezgah_replay.py +12 -6
  60. package/hooks/tezgah_research.py +1414 -76
  61. package/hooks/tezgah_research_capture.py +1544 -0
  62. package/hooks/tezgah_research_evidence.py +842 -0
  63. package/hooks/tezgah_research_stats.py +258 -0
  64. package/hooks/tezgah_skill_pick.py +408 -106
  65. package/hooks/tezgah_stack.py +236 -1
  66. package/hooks/tezgah_stopspec.py +8 -1
  67. package/hooks/tezgah_task.py +14 -5
  68. package/hooks/tezgah_taste_ledger.py +1 -1
  69. package/hosts/amp/hook.py +217 -0
  70. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  71. package/hosts/antigravity/hook.py +255 -0
  72. package/hosts/codex/hook.py +2 -1
  73. package/hosts/commandcode/hook.py +239 -0
  74. package/hosts/copilot/hook.py +284 -0
  75. package/hosts/cursor/hook.py +31 -3
  76. package/hosts/droid/hook.py +259 -0
  77. package/hosts/dsh/hooks.json +1 -1
  78. package/hosts/gemini/hook.py +213 -0
  79. package/hosts/goose/hook.py +145 -0
  80. package/hosts/omp/hook.py +4 -1
  81. package/hosts/omp/tezgah-hook.ts.in +30 -7
  82. package/hosts/opencode/plugins/tezgah.js +309 -48
  83. package/hosts/opencode/tui/cli/tui.tsx +100 -0
  84. package/hosts/opencode/tui/tezgah-tui.tsx +2 -1
  85. package/hosts/qwen/hook.py +206 -0
  86. package/output-styles/tezgah.md +20 -15
  87. package/package.json +1 -1
  88. package/skills/ai-research/SKILL.md +21 -14
  89. package/skills/ai-research/SOURCE +7 -5
  90. package/skills/ai-research/index/1-frame.md +6 -6
  91. package/skills/ai-research/index/2-data.md +9 -9
  92. package/skills/ai-research/index/3-train.md +36 -36
  93. package/skills/ai-research/index/4-measure.md +13 -13
  94. package/skills/ai-research/index/5-run.md +11 -11
  95. package/skills/ai-research/index/6-write.md +23 -23
  96. package/skills/ai-research/library.json +104 -104
  97. package/skills/analyze-app/SKILL.md +17 -12
  98. package/skills/design-contract/SKILL.md +42 -7
  99. package/skills/design-contract/data-views.md +2 -2
  100. package/skills/design-library/EVALS.md +1 -1
  101. package/skills/design-library/INDEX.md +39 -39
  102. package/skills/design-library/SKILL.md +19 -13
  103. package/skills/design-library/SOURCE +45 -43
  104. package/skills/feature-audit/SKILL.md +20 -14
  105. package/skills/hallmark/LICENSE +21 -0
  106. package/skills/hallmark/SKILL.md +154 -0
  107. package/skills/hallmark/SOURCE +207 -0
  108. package/skills/hallmark/agents/openai.yaml +13 -0
  109. package/skills/hallmark/references/anatomy.md +71 -0
  110. package/skills/hallmark/references/brand-truth.md +40 -0
  111. package/skills/hallmark/references/comp.md +104 -0
  112. package/skills/hallmark/references/craft.md +116 -0
  113. package/skills/hallmark/references/dark-mode.md +49 -0
  114. package/skills/hallmark/references/data-viz.md +48 -0
  115. package/skills/hallmark/references/design-md.md +53 -0
  116. package/skills/hallmark/references/direction.md +52 -0
  117. package/skills/hallmark/references/export-formats.md +80 -0
  118. package/skills/hallmark/references/flow.md +131 -0
  119. package/skills/hallmark/references/fonts.md +58 -0
  120. package/skills/hallmark/references/gates.md +243 -0
  121. package/skills/hallmark/references/genres/atmospheric.md +24 -0
  122. package/skills/hallmark/references/genres/editorial.md +24 -0
  123. package/skills/hallmark/references/genres/modern-minimal.md +24 -0
  124. package/skills/hallmark/references/genres/playful.md +24 -0
  125. package/skills/hallmark/references/imagery.md +99 -0
  126. package/skills/hallmark/references/interaction-and-states.md +89 -0
  127. package/skills/hallmark/references/layout.md +104 -0
  128. package/skills/hallmark/references/macrostructures/01-bento-grid.md +21 -0
  129. package/skills/hallmark/references/macrostructures/02-long-document.md +35 -0
  130. package/skills/hallmark/references/macrostructures/03-marquee-hero.md +21 -0
  131. package/skills/hallmark/references/macrostructures/04-stat-led.md +21 -0
  132. package/skills/hallmark/references/macrostructures/05-workbench.md +21 -0
  133. package/skills/hallmark/references/macrostructures/06-conversational-faq.md +28 -0
  134. package/skills/hallmark/references/macrostructures/07-manifesto.md +28 -0
  135. package/skills/hallmark/references/macrostructures/08-photographic.md +21 -0
  136. package/skills/hallmark/references/macrostructures/09-quote-led.md +21 -0
  137. package/skills/hallmark/references/macrostructures/10-specimen.md +21 -0
  138. package/skills/hallmark/references/macrostructures/11-catalogue.md +21 -0
  139. package/skills/hallmark/references/macrostructures/12-letter.md +21 -0
  140. package/skills/hallmark/references/macrostructures/13-index-first.md +21 -0
  141. package/skills/hallmark/references/macrostructures/14-narrative-workflow.md +21 -0
  142. package/skills/hallmark/references/macrostructures/15-split-studio.md +21 -0
  143. package/skills/hallmark/references/macrostructures/16-feature-stack.md +23 -0
  144. package/skills/hallmark/references/macrostructures/17-type-specimen.md +19 -0
  145. package/skills/hallmark/references/macrostructures/18-portfolio-grid.md +21 -0
  146. package/skills/hallmark/references/macrostructures/19-map-diagram.md +21 -0
  147. package/skills/hallmark/references/macrostructures/20-ecosystem-index.md +19 -0
  148. package/skills/hallmark/references/macrostructures/21-component-playground.md +21 -0
  149. package/skills/hallmark/references/macrostructures.md +119 -0
  150. package/skills/hallmark/references/microinteractions.md +80 -0
  151. package/skills/hallmark/references/patterns.md +202 -0
  152. package/skills/hallmark/references/redesign.md +88 -0
  153. package/skills/hallmark/references/responsive.md +46 -0
  154. package/skills/hallmark/references/review-finish.md +19 -0
  155. package/skills/hallmark/references/review-hero.md +20 -0
  156. package/skills/hallmark/references/scroll-choreography.md +110 -0
  157. package/skills/hallmark/references/texture.md +90 -0
  158. package/skills/hallmark/references/themes/README.md +58 -0
  159. package/skills/hallmark/references/themes/almanac.jpg +0 -0
  160. package/skills/hallmark/references/themes/almanac.md +103 -0
  161. package/skills/hallmark/references/themes/arcade.jpg +0 -0
  162. package/skills/hallmark/references/themes/arcade.md +102 -0
  163. package/skills/hallmark/references/themes/atelier.jpg +0 -0
  164. package/skills/hallmark/references/themes/atelier.md +98 -0
  165. package/skills/hallmark/references/themes/bloom.jpg +0 -0
  166. package/skills/hallmark/references/themes/bloom.md +102 -0
  167. package/skills/hallmark/references/themes/blueprint.jpg +0 -0
  168. package/skills/hallmark/references/themes/blueprint.md +103 -0
  169. package/skills/hallmark/references/themes/broadside.jpg +0 -0
  170. package/skills/hallmark/references/themes/broadside.md +108 -0
  171. package/skills/hallmark/references/themes/brutal.jpg +0 -0
  172. package/skills/hallmark/references/themes/brutal.md +95 -0
  173. package/skills/hallmark/references/themes/carnival.jpg +0 -0
  174. package/skills/hallmark/references/themes/carnival.md +104 -0
  175. package/skills/hallmark/references/themes/cobalt.jpg +0 -0
  176. package/skills/hallmark/references/themes/cobalt.md +101 -0
  177. package/skills/hallmark/references/themes/console.jpg +0 -0
  178. package/skills/hallmark/references/themes/console.md +99 -0
  179. package/skills/hallmark/references/themes/coral.jpg +0 -0
  180. package/skills/hallmark/references/themes/coral.md +102 -0
  181. package/skills/hallmark/references/themes/darkroom.jpg +0 -0
  182. package/skills/hallmark/references/themes/darkroom.md +99 -0
  183. package/skills/hallmark/references/themes/dose.jpg +0 -0
  184. package/skills/hallmark/references/themes/dose.md +98 -0
  185. package/skills/hallmark/references/themes/dusk.jpg +0 -0
  186. package/skills/hallmark/references/themes/dusk.md +105 -0
  187. package/skills/hallmark/references/themes/editorial.jpg +0 -0
  188. package/skills/hallmark/references/themes/editorial.md +97 -0
  189. package/skills/hallmark/references/themes/formwork.jpg +0 -0
  190. package/skills/hallmark/references/themes/formwork.md +107 -0
  191. package/skills/hallmark/references/themes/furrow.jpg +0 -0
  192. package/skills/hallmark/references/themes/furrow.md +98 -0
  193. package/skills/hallmark/references/themes/garden.jpg +0 -0
  194. package/skills/hallmark/references/themes/garden.md +97 -0
  195. package/skills/hallmark/references/themes/grid.jpg +0 -0
  196. package/skills/hallmark/references/themes/grid.md +101 -0
  197. package/skills/hallmark/references/themes/halftone.jpg +0 -0
  198. package/skills/hallmark/references/themes/halftone.md +106 -0
  199. package/skills/hallmark/references/themes/hedgerow.jpg +0 -0
  200. package/skills/hallmark/references/themes/hedgerow.md +104 -0
  201. package/skills/hallmark/references/themes/hum.jpg +0 -0
  202. package/skills/hallmark/references/themes/hum.md +99 -0
  203. package/skills/hallmark/references/themes/index.json +2191 -0
  204. package/skills/hallmark/references/themes/ink.jpg +0 -0
  205. package/skills/hallmark/references/themes/ink.md +106 -0
  206. package/skills/hallmark/references/themes/kiln.jpg +0 -0
  207. package/skills/hallmark/references/themes/kiln.md +108 -0
  208. package/skills/hallmark/references/themes/loom.jpg +0 -0
  209. package/skills/hallmark/references/themes/loom.md +106 -0
  210. package/skills/hallmark/references/themes/lumen.jpg +0 -0
  211. package/skills/hallmark/references/themes/lumen.md +103 -0
  212. package/skills/hallmark/references/themes/manifesto.jpg +0 -0
  213. package/skills/hallmark/references/themes/manifesto.md +101 -0
  214. package/skills/hallmark/references/themes/midnight.jpg +0 -0
  215. package/skills/hallmark/references/themes/midnight.md +97 -0
  216. package/skills/hallmark/references/themes/newsprint.jpg +0 -0
  217. package/skills/hallmark/references/themes/newsprint.md +102 -0
  218. package/skills/hallmark/references/themes/overprint.jpg +0 -0
  219. package/skills/hallmark/references/themes/overprint.md +101 -0
  220. package/skills/hallmark/references/themes/pasteup.jpg +0 -0
  221. package/skills/hallmark/references/themes/pasteup.md +100 -0
  222. package/skills/hallmark/references/themes/piste.jpg +0 -0
  223. package/skills/hallmark/references/themes/piste.md +99 -0
  224. package/skills/hallmark/references/themes/ribbon.jpg +0 -0
  225. package/skills/hallmark/references/themes/ribbon.md +106 -0
  226. package/skills/hallmark/references/themes/rim.jpg +0 -0
  227. package/skills/hallmark/references/themes/rim.md +96 -0
  228. package/skills/hallmark/references/themes/riso.jpg +0 -0
  229. package/skills/hallmark/references/themes/riso.md +98 -0
  230. package/skills/hallmark/references/themes/solstice.jpg +0 -0
  231. package/skills/hallmark/references/themes/solstice.md +104 -0
  232. package/skills/hallmark/references/themes/specimen.jpg +0 -0
  233. package/skills/hallmark/references/themes/specimen.md +100 -0
  234. package/skills/hallmark/references/themes/spore.jpg +0 -0
  235. package/skills/hallmark/references/themes/spore.md +104 -0
  236. package/skills/hallmark/references/themes/sport.jpg +0 -0
  237. package/skills/hallmark/references/themes/sport.md +101 -0
  238. package/skills/hallmark/references/themes/squeeze.jpg +0 -0
  239. package/skills/hallmark/references/themes/squeeze.md +99 -0
  240. package/skills/hallmark/references/themes/sticker.jpg +0 -0
  241. package/skills/hallmark/references/themes/sticker.md +106 -0
  242. package/skills/hallmark/references/themes/studio.jpg +0 -0
  243. package/skills/hallmark/references/themes/studio.md +99 -0
  244. package/skills/hallmark/references/themes/terminal.jpg +0 -0
  245. package/skills/hallmark/references/themes/terminal.md +96 -0
  246. package/skills/hallmark/references/themes/terrazzo.jpg +0 -0
  247. package/skills/hallmark/references/themes/terrazzo.md +106 -0
  248. package/skills/hallmark/references/themes/ultraviolet.jpg +0 -0
  249. package/skills/hallmark/references/themes/ultraviolet.md +106 -0
  250. package/skills/hallmark/references/themes/vellum.jpg +0 -0
  251. package/skills/hallmark/references/themes/vellum.md +105 -0
  252. package/skills/hallmark/references/themes/wayfind.jpg +0 -0
  253. package/skills/hallmark/references/themes/wayfind.md +105 -0
  254. package/skills/hallmark/references/themes/werkstatt.jpg +0 -0
  255. package/skills/hallmark/references/themes/werkstatt.md +105 -0
  256. package/skills/hallmark/scripts/capture.mjs +125 -0
  257. package/skills/hallmark/scripts/fixtures/exemplars/atmospheric.html +324 -0
  258. package/skills/hallmark/scripts/fixtures/exemplars/editorial.html +363 -0
  259. package/skills/hallmark/scripts/fixtures/exemplars/modern-minimal.html +527 -0
  260. package/skills/hallmark/scripts/fixtures/exemplars/playful.html +353 -0
  261. package/skills/hallmark/scripts/fixtures/eyebrow-fail/index.html +118 -0
  262. package/skills/hallmark/scripts/fixtures/eyebrow-pass/index.html +137 -0
  263. package/skills/hallmark/scripts/fixtures/kept/after.html +27 -0
  264. package/skills/hallmark/scripts/fixtures/kept/before.html +26 -0
  265. package/skills/hallmark/scripts/fixtures/slopped.html +145 -0
  266. package/skills/hallmark/scripts/fixtures/tailwind-fail/Feature.vue +14 -0
  267. package/skills/hallmark/scripts/fixtures/tailwind-fail/Hero.tsx +33 -0
  268. package/skills/hallmark/scripts/fixtures/tailwind-fail/index.html +74 -0
  269. package/skills/hallmark/scripts/fixtures/tailwind-fail/style.css +6 -0
  270. package/skills/hallmark/scripts/fixtures/tailwind-pass/Hero.tsx +33 -0
  271. package/skills/hallmark/scripts/fixtures/tailwind-pass/index.html +57 -0
  272. package/skills/hallmark/scripts/fixtures/tailwind-pass/style.css +15 -0
  273. package/skills/hallmark/scripts/fixtures/waiver/index.html +54 -0
  274. package/skills/hallmark/scripts/imagegen.mjs +85 -0
  275. package/skills/hallmark/scripts/install-hook.mjs +70 -0
  276. package/skills/hallmark/scripts/install.mjs +141 -0
  277. package/skills/hallmark/scripts/lint-hook.mjs +49 -0
  278. package/skills/hallmark/scripts/photos.json +1 -0
  279. package/skills/hallmark/scripts/picture.mjs +71 -0
  280. package/skills/hallmark/scripts/refcheck.mjs +352 -0
  281. package/skills/hallmark/scripts/sloplint-render.mjs +1252 -0
  282. package/skills/hallmark/scripts/sloplint.mjs +4422 -0
  283. package/skills/hallmark/scripts/theme.mjs +650 -0
  284. package/skills/harness/SKILL.md +17 -8
  285. package/skills/i-have-adhd/SKILL.md +32 -12
  286. package/skills/no-ai-slop/SKILL.md +15 -1
  287. package/skills/plan-add/SKILL.md +38 -27
  288. package/skills/plan-status/SKILL.md +13 -7
  289. package/skills/plan-sync/SKILL.md +18 -12
  290. package/skills/pm-frameworks/SKILL.md +19 -10
  291. package/skills/pm-frameworks/SOURCE +7 -5
  292. package/skills/ponytail/SKILL.md +19 -14
  293. package/skills/product-analysis/SKILL.md +22 -18
  294. package/skills/product-flow/SKILL.md +14 -9
  295. package/skills/research/SKILL.md +97 -48
  296. package/skills/rl-env/SKILL.md +25 -13
  297. package/skills/rl-env/SOURCE +8 -6
  298. package/skills/tezgah-contract/SKILL.md +65 -47
  299. package/statusline.py +20 -9
  300. package/workflows/graph-review.js +14 -2
  301. package/bin/tezgah-skill.py +0 -59
  302. /package/skills/ai-research/0-autoresearch-skill/{SKILL.md → ENTRY.md} +0 -0
  303. /package/skills/ai-research/01-model-architecture/litgpt/{SKILL.md → ENTRY.md} +0 -0
  304. /package/skills/ai-research/01-model-architecture/mamba/{SKILL.md → ENTRY.md} +0 -0
  305. /package/skills/ai-research/01-model-architecture/nanogpt/{SKILL.md → ENTRY.md} +0 -0
  306. /package/skills/ai-research/01-model-architecture/rwkv/{SKILL.md → ENTRY.md} +0 -0
  307. /package/skills/ai-research/01-model-architecture/torchtitan/{SKILL.md → ENTRY.md} +0 -0
  308. /package/skills/ai-research/02-tokenization/huggingface-tokenizers/{SKILL.md → ENTRY.md} +0 -0
  309. /package/skills/ai-research/02-tokenization/sentencepiece/{SKILL.md → ENTRY.md} +0 -0
  310. /package/skills/ai-research/03-fine-tuning/axolotl/{SKILL.md → ENTRY.md} +0 -0
  311. /package/skills/ai-research/03-fine-tuning/llama-factory/{SKILL.md → ENTRY.md} +0 -0
  312. /package/skills/ai-research/03-fine-tuning/peft/{SKILL.md → ENTRY.md} +0 -0
  313. /package/skills/ai-research/03-fine-tuning/unsloth/{SKILL.md → ENTRY.md} +0 -0
  314. /package/skills/ai-research/04-mechanistic-interpretability/nnsight/{SKILL.md → ENTRY.md} +0 -0
  315. /package/skills/ai-research/04-mechanistic-interpretability/pyvene/{SKILL.md → ENTRY.md} +0 -0
  316. /package/skills/ai-research/04-mechanistic-interpretability/saelens/{SKILL.md → ENTRY.md} +0 -0
  317. /package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/{SKILL.md → ENTRY.md} +0 -0
  318. /package/skills/ai-research/05-data-processing/nemo-curator/{SKILL.md → ENTRY.md} +0 -0
  319. /package/skills/ai-research/05-data-processing/ray-data/{SKILL.md → ENTRY.md} +0 -0
  320. /package/skills/ai-research/06-post-training/grpo-rl-training/{SKILL.md → ENTRY.md} +0 -0
  321. /package/skills/ai-research/06-post-training/miles/{SKILL.md → ENTRY.md} +0 -0
  322. /package/skills/ai-research/06-post-training/openrlhf/{SKILL.md → ENTRY.md} +0 -0
  323. /package/skills/ai-research/06-post-training/simpo/{SKILL.md → ENTRY.md} +0 -0
  324. /package/skills/ai-research/06-post-training/slime/{SKILL.md → ENTRY.md} +0 -0
  325. /package/skills/ai-research/06-post-training/torchforge/{SKILL.md → ENTRY.md} +0 -0
  326. /package/skills/ai-research/06-post-training/trl-fine-tuning/{SKILL.md → ENTRY.md} +0 -0
  327. /package/skills/ai-research/06-post-training/verl/{SKILL.md → ENTRY.md} +0 -0
  328. /package/skills/ai-research/07-safety-alignment/constitutional-ai/{SKILL.md → ENTRY.md} +0 -0
  329. /package/skills/ai-research/07-safety-alignment/llamaguard/{SKILL.md → ENTRY.md} +0 -0
  330. /package/skills/ai-research/07-safety-alignment/nemo-guardrails/{SKILL.md → ENTRY.md} +0 -0
  331. /package/skills/ai-research/07-safety-alignment/prompt-guard/{SKILL.md → ENTRY.md} +0 -0
  332. /package/skills/ai-research/08-distributed-training/accelerate/{SKILL.md → ENTRY.md} +0 -0
  333. /package/skills/ai-research/08-distributed-training/deepspeed/{SKILL.md → ENTRY.md} +0 -0
  334. /package/skills/ai-research/08-distributed-training/megatron-core/{SKILL.md → ENTRY.md} +0 -0
  335. /package/skills/ai-research/08-distributed-training/pytorch-fsdp2/{SKILL.md → ENTRY.md} +0 -0
  336. /package/skills/ai-research/08-distributed-training/pytorch-lightning/{SKILL.md → ENTRY.md} +0 -0
  337. /package/skills/ai-research/08-distributed-training/ray-train/{SKILL.md → ENTRY.md} +0 -0
  338. /package/skills/ai-research/09-infrastructure/lambda-labs/{SKILL.md → ENTRY.md} +0 -0
  339. /package/skills/ai-research/09-infrastructure/modal/{SKILL.md → ENTRY.md} +0 -0
  340. /package/skills/ai-research/09-infrastructure/skypilot/{SKILL.md → ENTRY.md} +0 -0
  341. /package/skills/ai-research/10-optimization/awq/{SKILL.md → ENTRY.md} +0 -0
  342. /package/skills/ai-research/10-optimization/bitsandbytes/{SKILL.md → ENTRY.md} +0 -0
  343. /package/skills/ai-research/10-optimization/flash-attention/{SKILL.md → ENTRY.md} +0 -0
  344. /package/skills/ai-research/10-optimization/gguf/{SKILL.md → ENTRY.md} +0 -0
  345. /package/skills/ai-research/10-optimization/gptq/{SKILL.md → ENTRY.md} +0 -0
  346. /package/skills/ai-research/10-optimization/hqq/{SKILL.md → ENTRY.md} +0 -0
  347. /package/skills/ai-research/10-optimization/ml-training-recipes/{SKILL.md → ENTRY.md} +0 -0
  348. /package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  349. /package/skills/ai-research/11-evaluation/lm-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  350. /package/skills/ai-research/11-evaluation/nemo-evaluator/{SKILL.md → ENTRY.md} +0 -0
  351. /package/skills/ai-research/12-inference-serving/llama-cpp/{SKILL.md → ENTRY.md} +0 -0
  352. /package/skills/ai-research/12-inference-serving/sglang/{SKILL.md → ENTRY.md} +0 -0
  353. /package/skills/ai-research/12-inference-serving/tensorrt-llm/{SKILL.md → ENTRY.md} +0 -0
  354. /package/skills/ai-research/12-inference-serving/vllm/{SKILL.md → ENTRY.md} +0 -0
  355. /package/skills/ai-research/13-mlops/mlflow/{SKILL.md → ENTRY.md} +0 -0
  356. /package/skills/ai-research/13-mlops/swanlab/{SKILL.md → ENTRY.md} +0 -0
  357. /package/skills/ai-research/13-mlops/tensorboard/{SKILL.md → ENTRY.md} +0 -0
  358. /package/skills/ai-research/13-mlops/weights-and-biases/{SKILL.md → ENTRY.md} +0 -0
  359. /package/skills/ai-research/14-agents/a-evolve/{SKILL.md → ENTRY.md} +0 -0
  360. /package/skills/ai-research/14-agents/autogpt/{SKILL.md → ENTRY.md} +0 -0
  361. /package/skills/ai-research/14-agents/crewai/{SKILL.md → ENTRY.md} +0 -0
  362. /package/skills/ai-research/14-agents/langchain/{SKILL.md → ENTRY.md} +0 -0
  363. /package/skills/ai-research/14-agents/llamaindex/{SKILL.md → ENTRY.md} +0 -0
  364. /package/skills/ai-research/15-rag/chroma/{SKILL.md → ENTRY.md} +0 -0
  365. /package/skills/ai-research/15-rag/faiss/{SKILL.md → ENTRY.md} +0 -0
  366. /package/skills/ai-research/15-rag/pinecone/{SKILL.md → ENTRY.md} +0 -0
  367. /package/skills/ai-research/15-rag/qdrant/{SKILL.md → ENTRY.md} +0 -0
  368. /package/skills/ai-research/15-rag/sentence-transformers/{SKILL.md → ENTRY.md} +0 -0
  369. /package/skills/ai-research/16-prompt-engineering/dspy/{SKILL.md → ENTRY.md} +0 -0
  370. /package/skills/ai-research/16-prompt-engineering/guidance/{SKILL.md → ENTRY.md} +0 -0
  371. /package/skills/ai-research/16-prompt-engineering/instructor/{SKILL.md → ENTRY.md} +0 -0
  372. /package/skills/ai-research/16-prompt-engineering/outlines/{SKILL.md → ENTRY.md} +0 -0
  373. /package/skills/ai-research/17-observability/langsmith/{SKILL.md → ENTRY.md} +0 -0
  374. /package/skills/ai-research/17-observability/phoenix/{SKILL.md → ENTRY.md} +0 -0
  375. /package/skills/ai-research/18-multimodal/audiocraft/{SKILL.md → ENTRY.md} +0 -0
  376. /package/skills/ai-research/18-multimodal/blip-2/{SKILL.md → ENTRY.md} +0 -0
  377. /package/skills/ai-research/18-multimodal/clip/{SKILL.md → ENTRY.md} +0 -0
  378. /package/skills/ai-research/18-multimodal/cosmos-policy/{SKILL.md → ENTRY.md} +0 -0
  379. /package/skills/ai-research/18-multimodal/llava/{SKILL.md → ENTRY.md} +0 -0
  380. /package/skills/ai-research/18-multimodal/openpi/{SKILL.md → ENTRY.md} +0 -0
  381. /package/skills/ai-research/18-multimodal/openvla-oft/{SKILL.md → ENTRY.md} +0 -0
  382. /package/skills/ai-research/18-multimodal/segment-anything/{SKILL.md → ENTRY.md} +0 -0
  383. /package/skills/ai-research/18-multimodal/stable-diffusion/{SKILL.md → ENTRY.md} +0 -0
  384. /package/skills/ai-research/18-multimodal/whisper/{SKILL.md → ENTRY.md} +0 -0
  385. /package/skills/ai-research/19-emerging-techniques/knowledge-distillation/{SKILL.md → ENTRY.md} +0 -0
  386. /package/skills/ai-research/19-emerging-techniques/long-context/{SKILL.md → ENTRY.md} +0 -0
  387. /package/skills/ai-research/19-emerging-techniques/model-merging/{SKILL.md → ENTRY.md} +0 -0
  388. /package/skills/ai-research/19-emerging-techniques/model-pruning/{SKILL.md → ENTRY.md} +0 -0
  389. /package/skills/ai-research/19-emerging-techniques/moe-training/{SKILL.md → ENTRY.md} +0 -0
  390. /package/skills/ai-research/19-emerging-techniques/speculative-decoding/{SKILL.md → ENTRY.md} +0 -0
  391. /package/skills/ai-research/20-ml-paper-writing/academic-plotting/{SKILL.md → ENTRY.md} +0 -0
  392. /package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  393. /package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/{SKILL.md → ENTRY.md} +0 -0
  394. /package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  395. /package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/{SKILL.md → ENTRY.md} +0 -0
  396. /package/skills/ai-research/21-research-ideation/creative-thinking-for-research/{SKILL.md → ENTRY.md} +0 -0
  397. /package/skills/ai-research/22-agent-native-research-artifact/compiler/{SKILL.md → ENTRY.md} +0 -0
  398. /package/skills/ai-research/22-agent-native-research-artifact/research-manager/{SKILL.md → ENTRY.md} +0 -0
  399. /package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/{SKILL.md → ENTRY.md} +0 -0
  400. /package/skills/design-library/adaptive-interfaces/skills/colour-independence/{SKILL.md → ENTRY.md} +0 -0
  401. /package/skills/design-library/adaptive-interfaces/skills/flexible-typography/{SKILL.md → ENTRY.md} +0 -0
  402. /package/skills/design-library/adaptive-interfaces/skills/information-density/{SKILL.md → ENTRY.md} +0 -0
  403. /package/skills/design-library/adaptive-interfaces/skills/preference-audit/{SKILL.md → ENTRY.md} +0 -0
  404. /package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/{SKILL.md → ENTRY.md} +0 -0
  405. /package/skills/design-library/adaptive-interfaces/skills/responsive-review/{SKILL.md → ENTRY.md} +0 -0
  406. /package/skills/design-library/adaptive-interfaces/skills/simplified-views/{SKILL.md → ENTRY.md} +0 -0
  407. /package/skills/design-library/adaptive-interfaces/skills/specify/{SKILL.md → ENTRY.md} +0 -0
  408. /package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/{SKILL.md → ENTRY.md} +0 -0
  409. /package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/{SKILL.md → ENTRY.md} +0 -0
  410. /package/skills/design-library/cognitive-accessibility/skills/assess-load/{SKILL.md → ENTRY.md} +0 -0
  411. /package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/{SKILL.md → ENTRY.md} +0 -0
  412. /package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/{SKILL.md → ENTRY.md} +0 -0
  413. /package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/{SKILL.md → ENTRY.md} +0 -0
  414. /package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/{SKILL.md → ENTRY.md} +0 -0
  415. /package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/{SKILL.md → ENTRY.md} +0 -0
  416. /package/skills/design-library/cognitive-accessibility/skills/plain-language-design/{SKILL.md → ENTRY.md} +0 -0
  417. /package/skills/design-library/cognitive-accessibility/skills/review/{SKILL.md → ENTRY.md} +0 -0
  418. /package/skills/design-library/cognitive-accessibility/skills/simplify/{SKILL.md → ENTRY.md} +0 -0
  419. /package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/{SKILL.md → ENTRY.md} +0 -0
  420. /package/skills/design-library/design-research/skills/behavioural-analytics/{SKILL.md → ENTRY.md} +0 -0
  421. /package/skills/design-library/design-research/skills/qual-quant-triangulation/{SKILL.md → ENTRY.md} +0 -0
  422. /package/skills/design-library/inclusive-interaction/skills/audit/{SKILL.md → ENTRY.md} +0 -0
  423. /package/skills/design-library/inclusive-interaction/skills/design-flow/{SKILL.md → ENTRY.md} +0 -0
  424. /package/skills/design-library/inclusive-interaction/skills/feedback-and-status/{SKILL.md → ENTRY.md} +0 -0
  425. /package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/{SKILL.md → ENTRY.md} +0 -0
  426. /package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/{SKILL.md → ENTRY.md} +0 -0
  427. /package/skills/design-library/inclusive-interaction/skills/keyboard-review/{SKILL.md → ENTRY.md} +0 -0
  428. /package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/{SKILL.md → ENTRY.md} +0 -0
  429. /package/skills/design-library/inclusive-interaction/skills/multi-modal-input/{SKILL.md → ENTRY.md} +0 -0
  430. /package/skills/design-library/inclusive-interaction/skills/touch-target-design/{SKILL.md → ENTRY.md} +0 -0
  431. /package/skills/design-library/inclusive-interaction/skills/voice-interaction/{SKILL.md → ENTRY.md} +0 -0
  432. /package/skills/design-library/visual-critique/skills/critique-affordance/{SKILL.md → ENTRY.md} +0 -0
  433. /package/skills/design-library/visual-critique/skills/critique-brand-consistency/{SKILL.md → ENTRY.md} +0 -0
  434. /package/skills/design-library/visual-critique/skills/critique-color/{SKILL.md → ENTRY.md} +0 -0
  435. /package/skills/design-library/visual-critique/skills/critique-composition/{SKILL.md → ENTRY.md} +0 -0
  436. /package/skills/design-library/visual-critique/skills/critique-information-density/{SKILL.md → ENTRY.md} +0 -0
  437. /package/skills/design-library/visual-critique/skills/critique-typography/{SKILL.md → ENTRY.md} +0 -0
  438. /package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/{SKILL.md → ENTRY.md} +0 -0
  439. /package/skills/pm-frameworks/intended-vs-implemented/{SKILL.md → ENTRY.md} +0 -0
  440. /package/skills/pm-frameworks/opportunity-solution-tree/{SKILL.md → ENTRY.md} +0 -0
  441. /package/skills/rl-env/generate-nemo-gym-env/{SKILL.md → ENTRY.md} +0 -0
  442. /package/skills/rl-env/generate-openenv-env/{SKILL.md → ENTRY.md} +0 -0
  443. /package/skills/rl-env/generate-ors-env/{SKILL.md → ENTRY.md} +0 -0
  444. /package/skills/rl-env/generate-verifiers-env/{SKILL.md → ENTRY.md} +0 -0
  445. /package/skills/rl-env/rl-env-from-description/{SKILL.md → ENTRY.md} +0 -0
@@ -0,0 +1,4422 @@
1
+ #!/usr/bin/env node
2
+ /* sloplint: Hallmark's mechanical gate checker. Zero dependencies, Node 18+. Gate ids and tiers: references/gates.md. */
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join, extname, dirname, basename, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const GENRES = ['editorial', 'modern-minimal', 'atmospheric', 'playful'];
9
+ const args = process.argv.slice(2);
10
+ const opts = { genre: null, scope: 'page', json: false, render: false, tier: null, stage: 'page', before: false, kept: false, urls: [], paths: [] };
11
+ for (let i = 0; i < args.length; i++) {
12
+ const a = args[i];
13
+ if (a === '--genre') { opts.genre = args[++i]; }
14
+ else if (a === '--scope') { opts.scope = args[++i]; }
15
+ else if (a === '--json') { opts.json = true; }
16
+ else if (a === '--render') { opts.render = true; }
17
+ else if (a === '--before') { opts.before = true; }
18
+ else if (a === '--kept') { opts.kept = true; }
19
+ else if (a === '--tier') { opts.tier = args[++i]; }
20
+ else if (a === '--stage') { opts.stage = args[++i]; }
21
+ else if (a === '--url') { opts.urls.push(args[++i]); opts.render = true; }
22
+ else if (a === '--help' || a === '-h') { usage(); process.exit(0); }
23
+ else if (a.startsWith('--')) { console.error(`unknown flag ${a}`); usage(); process.exit(2); }
24
+ else opts.paths.push(a);
25
+ }
26
+ function usage() {
27
+ console.error('usage: node sloplint.mjs <file-or-dir ...> [--genre editorial|modern-minimal|atmospheric|playful] [--scope page|component|app] [--stage hero|page] [--json] [--render] [--url http://localhost:3000] [--tier floor] [--before | --kept]');
28
+ console.error('--before records what the target says and does (.hallmark/before.json, screenshots in .hallmark/before); --kept checks a redesign against that record');
29
+ console.error('reads .html and .css, plus .jsx .tsx .vue .svelte .astro (Tailwind class strings, cn/clsx/cva calls); skips node_modules, dist, build, coverage and dot folders');
30
+ }
31
+ if (!opts.paths.length && !opts.urls.length) { usage(); process.exit(2); }
32
+ if (!['hero', 'page'].includes(opts.stage)) { console.error('--stage must be hero or page'); process.exit(2); }
33
+ if (opts.urls.some((u) => !/^https?:\/\//.test(u || ''))) { console.error('--url takes an http or https address'); process.exit(2); }
34
+ if (opts.genre && !GENRES.includes(opts.genre)) {
35
+ console.error(`--genre must be one of: ${GENRES.join(', ')}`); process.exit(2);
36
+ }
37
+ if (!['page', 'component', 'app'].includes(opts.scope)) {
38
+ console.error('--scope must be page, component or app'); process.exit(2);
39
+ }
40
+ if (opts.before && opts.kept) { console.error('--before records and --kept compares; pass one'); process.exit(2); }
41
+
42
+ const DOC_EXT = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']);
43
+ const BUILD_DIRS = new Set(['dist', 'build', 'coverage']);
44
+ function collectFiles(paths) {
45
+ const files = [];
46
+ const seen = new Set();
47
+ const visit = (p, top) => {
48
+ const abs = resolve(p);
49
+ if (seen.has(abs)) return;
50
+ let st;
51
+ try { st = statSync(abs); } catch { console.error(`sloplint: cannot read ${p}`); return; }
52
+ if (st.isDirectory()) {
53
+ const base = basename(abs);
54
+ if (base === 'node_modules' || base.startsWith('.') || (!top && BUILD_DIRS.has(base))) return;
55
+ for (const e of readdirSync(abs)) visit(join(abs, e), false);
56
+ } else {
57
+ const ext = extname(abs).toLowerCase();
58
+ if (ext === '.css' || DOC_EXT.has(ext)) { seen.add(abs); files.push(abs); }
59
+ }
60
+ };
61
+ for (const p of paths) visit(p, true);
62
+ return files;
63
+ }
64
+
65
+ function stripComments(src) {
66
+ let out = '';
67
+ let i = 0;
68
+ while (i < src.length) {
69
+ if (src[i] === '/' && src[i + 1] === '*') {
70
+ const end = src.indexOf('*/', i + 2);
71
+ const stop = end === -1 ? src.length : end + 2;
72
+ out += src.slice(i, stop).replace(/[^\n]/g, ' ');
73
+ i = stop;
74
+ } else if (src[i] === '"' || src[i] === "'") {
75
+ const q = src[i]; let j = i + 1;
76
+ while (j < src.length && src[j] !== q) { if (src[j] === '\\') j++; j++; }
77
+ out += src.slice(i, Math.min(j + 1, src.length)); i = j + 1;
78
+ } else { out += src[i]; i++; }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function parseCss(source, file, lineOffset = 0) {
84
+ const src = stripComments(source);
85
+ const nl = [];
86
+ for (let i = 0; i < src.length; i++) if (src[i] === '\n') nl.push(i);
87
+ const lineOf = (pos) => {
88
+ let lo = 0, hi = nl.length;
89
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (nl[mid] < pos) lo = mid + 1; else hi = mid; }
90
+ return lo + 1 + lineOffset;
91
+ };
92
+ const rules = [];
93
+ const flatBody = (s, e) => {
94
+ const out = src.slice(s, e).split('');
95
+ let depth = 0, edge = -1;
96
+ for (let i = 0; i < out.length; i++) {
97
+ const c = out[i];
98
+ if (c === '{') {
99
+ if (depth === 0) for (let k = edge + 1; k < i; k++) if (out[k] !== '\n') out[k] = ' ';
100
+ depth++; out[i] = ' ';
101
+ } else if (c === '}') {
102
+ depth = Math.max(0, depth - 1); out[i] = ' ';
103
+ if (depth === 0) edge = i;
104
+ } else if (depth > 0) { if (c !== '\n') out[i] = ' '; }
105
+ else if (c === ';') edge = i;
106
+ }
107
+ return out.join('');
108
+ };
109
+ const nestSel = (sel, parent) => {
110
+ const p = parent.includes(',') ? `:is(${parent})` : parent;
111
+ return sel.split(/,(?![^(]*\))/).map((part) => part.includes('&') ? part.trim().replace(/&/g, p) : `${p} ${part.trim()}`).join(', ');
112
+ };
113
+ const makeRule = (selector, s, e, media, keyframes) => {
114
+ const body = flatBody(s, e);
115
+ const decls = [];
116
+ let off = 0;
117
+ for (const part of body.split(';')) {
118
+ const idx = part.indexOf(':');
119
+ if (idx > 0) {
120
+ const prop = part.slice(0, idx).trim().toLowerCase();
121
+ const value = part.slice(idx + 1).trim().replace(/!important\s*$/i, '').trim();
122
+ if (prop && value && /^-{0,2}[a-z]/.test(prop) && !/[{}]/.test(prop)) {
123
+ decls.push({ prop, value, line: lineOf(s + off + idx) });
124
+ }
125
+ }
126
+ off += part.length + 1;
127
+ }
128
+ return { selector, decls, media: media || null, keyframes: keyframes || null, file, line: lineOf(s) };
129
+ };
130
+ const walk = (start, end, media, keyframes, parent = null) => {
131
+ let i = start;
132
+ while (i < end) {
133
+ while (i < end && /[\s;]/.test(src[i])) i++;
134
+ if (i >= end) break;
135
+ let j = i;
136
+ while (j < end && src[j] !== '{' && src[j] !== '}' && src[j] !== ';') j++;
137
+ if (j >= end) break;
138
+ if (src[j] === '}' || src[j] === ';') { i = j + 1; continue; }
139
+ const selector = src.slice(i, j).trim().replace(/\s+/g, ' ');
140
+ const blockStart = j + 1;
141
+ let depth = 1, k = blockStart;
142
+ while (k < end && depth > 0) {
143
+ if (src[k] === '{') depth++;
144
+ else if (src[k] === '}') depth--;
145
+ k++;
146
+ }
147
+ const blockEnd = Math.max(blockStart, k - 1);
148
+ if (selector.startsWith('@')) {
149
+ if (/^@(media|supports|layer|container|scope)/i.test(selector)) {
150
+ const q = /^@media/i.test(selector) ? selector.replace(/^@media/i, '').trim() : media;
151
+ if (parent) rules.push(makeRule(parent, blockStart, blockEnd, q, keyframes));
152
+ walk(blockStart, blockEnd, q, keyframes, parent);
153
+ } else if (/^@(-webkit-)?keyframes/i.test(selector)) {
154
+ const name = selector.replace(/^@(-webkit-)?keyframes/i, '').trim();
155
+ walk(blockStart, blockEnd, media, name || 'anon');
156
+ } else if (/^@font-face/i.test(selector)) {
157
+ rules.push(makeRule('@font-face', blockStart, blockEnd, media, null));
158
+ } else if (/^@theme\b/i.test(selector)) {
159
+ rules.push(makeRule('@theme', blockStart, blockEnd, media, null));
160
+ walk(blockStart, blockEnd, media, keyframes);
161
+ }
162
+ } else {
163
+ const sel = parent && !keyframes ? nestSel(selector, parent) : selector;
164
+ rules.push(makeRule(sel, blockStart, blockEnd, media, keyframes));
165
+ if (!keyframes && src.slice(blockStart, blockEnd).includes('{')) walk(blockStart, blockEnd, media, keyframes, sel);
166
+ }
167
+ i = k;
168
+ }
169
+ };
170
+ walk(0, src.length, null, null);
171
+ return rules;
172
+ }
173
+
174
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
175
+ const HTML_NAMES = new Set(('a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p param picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup svg table tbody td template textarea tfoot th thead time title tr track u ul var video wbr').split(' '));
176
+
177
+ function parseHtml(file, src, kind = 'html') {
178
+ const nl = [];
179
+ for (let i = 0; i < src.length; i++) if (src[i] === '\n') nl.push(i);
180
+ const lineOf = (pos) => {
181
+ let lo = 0, hi = nl.length;
182
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (nl[mid] < pos) lo = mid + 1; else hi = mid; }
183
+ return lo + 1;
184
+ };
185
+ const styles = [];
186
+ const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
187
+ let m;
188
+ while ((m = styleRe.exec(src))) {
189
+ styles.push({ css: m[1], lineOffset: lineOf(m.index) - 1 });
190
+ }
191
+ let scripts = '';
192
+ const scriptRe = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
193
+ while ((m = scriptRe.exec(src))) scripts += m[1] + '\n';
194
+
195
+ const pad = (t) => t.replace(/[^\n]/g, ' ');
196
+ const masked = src
197
+ .replace(/<!--[\s\S]*?-->/g, pad)
198
+ .replace(/(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi, (m0, open, tag, body, close) => open + pad(body) + close);
199
+ const tags = [];
200
+ const comps = [];
201
+ const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
202
+ while ((m = tagRe.exec(masked))) {
203
+ if (kind !== 'html' && !HTML_NAMES.has(m[2])) {
204
+ comps.push({ close: m[1] === '/', name: m[2], attrs: m[3] || '', pos: m.index, end: m.index + m[0].length, selfClosing: /\/\s*$/.test(m[3] || ''), line: lineOf(m.index) });
205
+ continue;
206
+ }
207
+ tags.push({
208
+ close: m[1] === '/', name: m[2].toLowerCase(), attrs: m[3] || '',
209
+ pos: m.index, end: m.index + m[0].length,
210
+ selfClosing: /\/\s*$/.test(m[3] || '') || VOID_TAGS.has(m[2].toLowerCase()),
211
+ line: lineOf(m.index),
212
+ });
213
+ }
214
+ const noScript = src.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
215
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
216
+ .replace(/<!--[\s\S]*?-->/g, ' ');
217
+ const text = noScript.replace(/<[^>]*>/g, ' ')
218
+ .replace(/&quot;|&#0?34;/g, '"').replace(/&#0?39;|&apos;/g, "'")
219
+ .replace(/&hellip;|&#8230;/g, '\u2026').replace(/&mdash;|&#8212;|&#x2014;/gi, '\u2014').replace(/&ndash;|&#8211;|&#x2013;/gi, '\u2013')
220
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
221
+ .replace(/&nbsp;/g, ' ').replace(/[ \t]+/g, ' ');
222
+ return { file, src, masked, styles, scripts, tags, text, lineOf, kind, comps };
223
+ }
224
+
225
+ function braceEnd(s, j) {
226
+ let depth = 0;
227
+ for (let i = j; i < s.length; i++) {
228
+ const c = s[i];
229
+ if (c === '"' || c === "'" || c === '`') {
230
+ i++;
231
+ while (i < s.length && s[i] !== c) { if (s[i] === '\\') i++; i++; }
232
+ continue;
233
+ }
234
+ if (c === '{') depth++;
235
+ else if (c === '}' && --depth === 0) return i;
236
+ }
237
+ return s.length;
238
+ }
239
+
240
+ const FRONTMATTER = /^\s*---[^\n]*\n[\s\S]*?\n---/;
241
+ function templateMask(src, kind) {
242
+ const n = src.length;
243
+ let out = null;
244
+ const put = (k, ch) => { if (!out) out = src.split(''); out[k] = ch; };
245
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (src[k] !== '\n' && src[k] !== ' ') put(k, ' '); };
246
+ const lower = src.toLowerCase();
247
+ const braces = kind === 'svelte' || kind === 'astro';
248
+ let i = 0;
249
+ if (kind === 'astro') {
250
+ const fm = FRONTMATTER.exec(src);
251
+ if (fm) { blank(0, fm[0].length); i = fm[0].length; }
252
+ }
253
+ while (i < n) {
254
+ const lt = src.indexOf('<', i);
255
+ if (lt < 0) break;
256
+ if (src.startsWith('<!--', lt)) { const e = src.indexOf('-->', lt + 4); i = e < 0 ? n : e + 3; continue; }
257
+ if (!/[A-Za-z]/.test(src[lt + 1] || '')) { i = lt + 1; continue; }
258
+ let j = lt + 1;
259
+ while (j < n && /[\w:.-]/.test(src[j])) j++;
260
+ const name = lower.slice(lt + 1, j);
261
+ while (j < n && src[j] !== '>') {
262
+ const c = src[j];
263
+ if (c === '"' || c === "'") {
264
+ let e = src.indexOf(c, j + 1);
265
+ if (e < 0) e = n;
266
+ for (let k = j + 1; k < e; k++) if (src[k] === '<' || src[k] === '>') put(k, ' ');
267
+ j = e + 1;
268
+ continue;
269
+ }
270
+ if (c === '{' && braces) { const e = braceEnd(src, j); blank(j + 1, e); j = e + 1; continue; }
271
+ j++;
272
+ }
273
+ i = j + 1;
274
+ if (name === 'script' || name === 'style' || name === 'textarea') {
275
+ const e = lower.indexOf('</' + name, i);
276
+ i = e < 0 ? n : e;
277
+ }
278
+ }
279
+ return out ? out.join('') : src;
280
+ }
281
+
282
+ const cutGlued = (seg, after, before) => {
283
+ let s = seg;
284
+ if (after) s = s.replace(/^\S+/, '');
285
+ if (before) s = s.replace(/\S+$/, '');
286
+ return s;
287
+ };
288
+ function clsLits(s, level = 0) {
289
+ if (level > 20) return [];
290
+ const parts = [];
291
+ let i = 0;
292
+ while (i < s.length) {
293
+ const c = s[i];
294
+ if (c === '/' && s[i + 1] === '/') { const e = s.indexOf('\n', i); i = e < 0 ? s.length : e; continue; }
295
+ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 ? s.length : e + 2; continue; }
296
+ if (c === '"' || c === "'") {
297
+ let j = i + 1;
298
+ while (j < s.length && s[j] !== c && s[j] !== '\n') { if (s[j] === '\\') j++; j++; }
299
+ parts.push(s.slice(i + 1, j));
300
+ i = j + 1;
301
+ continue;
302
+ }
303
+ if (c === '`') {
304
+ let j = i + 1, seg = '', after = false;
305
+ while (j < s.length && s[j] !== '`') {
306
+ if (s[j] === '\\') { seg += s[j + 1] || ''; j += 2; continue; }
307
+ if (s[j] === '$' && s[j + 1] === '{') {
308
+ parts.push(cutGlued(seg, after, true));
309
+ const e = braceEnd(s, j + 1);
310
+ parts.push(clsLits(s.slice(j + 2, e), level + 1).join(' '));
311
+ j = e + 1; seg = ''; after = true;
312
+ continue;
313
+ }
314
+ seg += s[j]; j++;
315
+ }
316
+ parts.push(cutGlued(seg, after, false));
317
+ i = j + 1;
318
+ continue;
319
+ }
320
+ i++;
321
+ }
322
+ return parts.join(' ').split(/\s+/).filter(Boolean);
323
+ }
324
+
325
+ const JS_EXPR_KW = new Set(['return', 'yield', 'await', 'case', 'default', 'else', 'do', 'throw', 'typeof', 'void', 'delete', 'in', 'of', 'new', 'instanceof']);
326
+ const CLASS_FNS = new Set(['cn', 'clsx', 'classNames', 'classnames', 'twMerge', 'twJoin', 'cva', 'cx', 'tv']);
327
+ function jsxPseudo(src) {
328
+ const n = src.length;
329
+ const out = new Array(n);
330
+ for (let k = 0; k < n; k++) out[k] = src[k] === '\n' ? '\n' : ' ';
331
+ const classes = [];
332
+ const calls = [];
333
+ let hi = 0, fails = 0, inClass = 0, depth = 0;
334
+ const blank = (a, b) => { for (let k = a; k < b && k < n; k++) out[k] = src[k] === '\n' ? '\n' : ' '; };
335
+ const paint = (a, b) => { for (let k = a; k < b && k < n; k++) out[k] = src[k]; if (b > hi) hi = b; };
336
+ const write = (a, b, text) => {
337
+ blank(a, b);
338
+ let k = a;
339
+ for (const ch of text) { while (k < b && src[k] === '\n') k++; if (k >= b) break; out[k++] = ch; }
340
+ if (b > hi) hi = b;
341
+ };
342
+ const ws = (i) => { while (i < n && /\s/.test(src[i])) i++; return i; };
343
+ const gap = (i) => {
344
+ for (;;) {
345
+ i = ws(i);
346
+ if (src[i] === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); if (e < 0) return n; i = e + 2; continue; }
347
+ if (src[i] === '/' && src[i + 1] === '/') { const e = src.indexOf('\n', i); if (e < 0) return n; i = e; continue; }
348
+ return i;
349
+ }
350
+ };
351
+ const strEnd = (i) => {
352
+ const q = src[i];
353
+ let j = i + 1;
354
+ while (j < n && src[j] !== q && src[j] !== '\n') { if (src[j] === '\\') j++; j++; }
355
+ return Math.min(j + 1, n);
356
+ };
357
+ const tplEnd = (i) => {
358
+ let j = i + 1;
359
+ while (j < n && src[j] !== '`') {
360
+ if (src[j] === '\\') { j += 2; continue; }
361
+ if (src[j] === '$' && src[j + 1] === '{') { j = js(j + 2, true) + 1; continue; }
362
+ j++;
363
+ }
364
+ return Math.min(j + 1, n);
365
+ };
366
+ const reEnd = (i) => {
367
+ let j = i + 1, set = false;
368
+ while (j < n && src[j] !== '\n') {
369
+ const c = src[j];
370
+ if (c === '\\') { j += 2; continue; }
371
+ if (c === '[') set = true;
372
+ else if (c === ']') set = false;
373
+ else if (c === '/' && !set) { j++; while (j < n && /[a-z]/i.test(src[j])) j++; return j; }
374
+ j++;
375
+ }
376
+ return -1;
377
+ };
378
+ function js(i, stop) {
379
+ if (depth > 200) return stop ? Math.min(braceEnd(src, i - 1), n) : n;
380
+ depth++;
381
+ const r = jsScan(i, stop);
382
+ depth--;
383
+ return r;
384
+ }
385
+ function jsScan(i, stop) {
386
+ let level = 0, val = false;
387
+ while (i < n) {
388
+ const c = src[i];
389
+ if (c === ' ' || c === '\n' || c === '\t' || c === '\r') { i++; continue; }
390
+ if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\n', i); i = e < 0 ? n : e; continue; }
391
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
392
+ if (c === '"' || c === "'") { i = strEnd(i); val = true; continue; }
393
+ if (c === '`') { i = tplEnd(i); val = true; continue; }
394
+ if (c === '{') { level++; i++; val = false; continue; }
395
+ if (c === '}') { if (stop && level === 0) return i; level--; i++; val = true; continue; }
396
+ if (c === ')' || c === ']') { i++; val = true; continue; }
397
+ if (c === '<' && !val && fails < 500 && /[A-Za-z_$>]/.test(src[i + 1] || '')) {
398
+ const e = el(i);
399
+ if (e > 0) { i = e; val = true; continue; }
400
+ fails++;
401
+ }
402
+ if (c === '/' && !val) { const e = reEnd(i); if (e > 0) { i = e; val = true; continue; } }
403
+ if (/[A-Za-z_$]/.test(c)) {
404
+ let j = i + 1;
405
+ while (j < n && /[\w$]/.test(src[j])) j++;
406
+ const w = src.slice(i, j);
407
+ val = !JS_EXPR_KW.has(w);
408
+ if (!inClass && CLASS_FNS.has(w) && src[i - 1] !== '.' && src[ws(j)] === '(') calls.push(i);
409
+ i = j;
410
+ continue;
411
+ }
412
+ if (c >= '0' && c <= '9') { let j = i + 1; while (j < n && /[\w.]/.test(src[j])) j++; i = j; val = true; continue; }
413
+ i++;
414
+ val = false;
415
+ }
416
+ return n;
417
+ }
418
+ function el(start) {
419
+ if (depth > 200) return -1;
420
+ const mc = classes.length, mk = calls.length;
421
+ depth++;
422
+ const e = tag(start);
423
+ depth--;
424
+ if (e < 0) { blank(start, Math.max(hi, start + 1)); hi = start; classes.length = mc; calls.length = mk; }
425
+ return e;
426
+ }
427
+ function tag(start) {
428
+ let i = start + 1;
429
+ if (src[i] === '>') return kids(i + 1, '');
430
+ let j = i;
431
+ while (j < n && /[\w$.:-]/.test(src[j])) j++;
432
+ const name = src.slice(i, j);
433
+ paint(start, j);
434
+ i = j;
435
+ let cls = null;
436
+ for (;;) {
437
+ i = gap(i);
438
+ if (i >= n) return -1;
439
+ const c = src[i];
440
+ if (c === '/' && src[i + 1] === '>') { paint(i, i + 2); if (cls) classes.push([start, cls]); return i + 2; }
441
+ if (c === '>') { paint(i, i + 1); i++; break; }
442
+ if (c === '{') { const e = js(i + 1, true); if (e >= n) return -1; blank(i, e + 1); i = e + 1; continue; }
443
+ if (!/[A-Za-z_$]/.test(c)) return -1;
444
+ let k = i + 1;
445
+ while (k < n && /[\w$:.-]/.test(src[k])) k++;
446
+ const attr = src.slice(i, k);
447
+ let v = ws(k);
448
+ if (src[v] !== '=') { paint(i, k); i = k; continue; }
449
+ v = ws(v + 1);
450
+ const q = src[v];
451
+ const isCls = attr === 'className' || attr === 'class';
452
+ let e;
453
+ if (q === '"' || q === "'") {
454
+ e = src.indexOf(q, v + 1);
455
+ if (e < 0) return -1;
456
+ if (isCls) cls = (cls || []).concat(src.slice(v + 1, e).split(/\s+/).filter(Boolean));
457
+ else paint(i, e + 1);
458
+ } else if (q === '{') {
459
+ if (isCls) inClass++;
460
+ e = js(v + 1, true);
461
+ if (isCls) inClass--;
462
+ if (e >= n) return -1;
463
+ if (isCls) cls = (cls || []).concat(clsLits(src.slice(v + 1, e)));
464
+ else { paint(i, v); write(v, e + 1, '"' + src.slice(v + 1, e).replace(/[^\w$.\-/:#@]+/g, ' ') + '"'); }
465
+ } else if (q === '<') {
466
+ e = el(v) - 1;
467
+ if (e < 0) return -1;
468
+ blank(i, e + 1);
469
+ } else return -1;
470
+ if (isCls && cls) write(i, e + 1, 'class="' + cls.join(' ').replace(/"/g, "'").replace(/[<>]/g, ' ') + '"');
471
+ i = e + 1;
472
+ }
473
+ if (cls) classes.push([start, cls]);
474
+ return kids(i, name);
475
+ }
476
+ function kids(i, name) {
477
+ while (i < n) {
478
+ const c = src[i];
479
+ if (c === '{') { const e = js(i + 1, true); if (e >= n) return -1; i = e + 1; continue; }
480
+ if (c === '<') {
481
+ if (src[i + 1] === '/') {
482
+ const k = ws(i + 2);
483
+ let j = k;
484
+ while (j < n && /[\w$.:-]/.test(src[j])) j++;
485
+ const e = ws(j);
486
+ if (src[e] !== '>' || src.slice(k, j) !== name) return -1;
487
+ if (name) paint(i, e + 1);
488
+ return e + 1;
489
+ }
490
+ if (/[A-Za-z_$>]/.test(src[i + 1] || '')) { const e = el(i); if (e > 0) { i = e; continue; } }
491
+ i++;
492
+ continue;
493
+ }
494
+ out[i] = c;
495
+ if (i >= hi) hi = i + 1;
496
+ i++;
497
+ }
498
+ return -1;
499
+ }
500
+ js(0, false);
501
+ return { pseudo: out.join(''), classAt: new Map(classes), calls };
502
+ }
503
+
504
+ function classCalls(src, sites) {
505
+ const out = [];
506
+ let last = -1;
507
+ for (const pos of [...sites].sort((a, b) => a - b)) {
508
+ if (pos < last) continue;
509
+ const open = src.indexOf('(', pos);
510
+ if (open < 0) continue;
511
+ let depth = 0, i = open;
512
+ for (; i < src.length; i++) {
513
+ const c = src[i];
514
+ if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\n', i); i = e < 0 ? src.length : e; continue; }
515
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue; }
516
+ if (c === '"' || c === "'" || c === '`') { i++; while (i < src.length && src[i] !== c) { if (src[i] === '\\') i++; i++; } continue; }
517
+ if (c === '(') depth++;
518
+ else if (c === ')' && --depth === 0) break;
519
+ }
520
+ last = i;
521
+ const toks = clsLits(src.slice(open + 1, i));
522
+ if (toks.length) out.push({ pos, name: /^[\w$]+/.exec(src.slice(pos))[0], toks });
523
+ }
524
+ return out;
525
+ }
526
+
527
+ function scriptSites(src, kind) {
528
+ const ranges = [];
529
+ for (const m of src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) ranges.push([m.index + m[0].length - m[1].length - 9, m.index + m[0].length - 9]);
530
+ const fm = kind === 'astro' ? FRONTMATTER.exec(src) : null;
531
+ if (fm) ranges.push([0, fm[0].length]);
532
+ const sites = [];
533
+ for (const [a, b] of ranges) {
534
+ for (const m of src.slice(a, b).matchAll(/(?<![\w$.])(?:cn|clsx|classNames|classnames|twMerge|twJoin|cva|cx|tv)\s*\(/g)) sites.push(a + m.index);
535
+ }
536
+ return sites;
537
+ }
538
+
539
+ function parseDoc(file, raw) {
540
+ const ext = extname(file).toLowerCase();
541
+ if (ext === '.jsx' || ext === '.tsx') {
542
+ const j = jsxPseudo(raw);
543
+ const doc = parseHtml(file, j.pseudo, 'jsx');
544
+ doc.styles = [];
545
+ doc.scripts = raw;
546
+ doc.classAt = j.classAt;
547
+ doc.virtuals = classCalls(raw, j.calls);
548
+ doc.raw = raw;
549
+ return doc;
550
+ }
551
+ const kind = ext === '.html' ? 'html' : ext.slice(1);
552
+ const doc = parseHtml(file, templateMask(raw, kind), kind);
553
+ doc.raw = raw;
554
+ doc.virtuals = kind === 'html' ? [] : classCalls(raw, scriptSites(raw, kind));
555
+ return doc;
556
+ }
557
+
558
+ function getAttr(attrStr, name) {
559
+ const re = new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>/]+))`, 'i');
560
+ const m = re.exec(attrStr);
561
+ if (!m) return null;
562
+ return m[2] ?? m[3] ?? m[4] ?? '';
563
+ }
564
+ function hasAttr(attrStr, name) {
565
+ return new RegExp(`(?:^|\\s)${name}(?=[\\s=/]|$)`, 'i').test(attrStr);
566
+ }
567
+
568
+ function directChildren(doc, idx) {
569
+ const open = doc.tags[idx];
570
+ if (open.close || open.selfClosing) return [];
571
+ const children = [];
572
+ let depth = 1;
573
+ let childStart = -1, childOpen = null, childIdx = -1;
574
+ for (let i = idx + 1; i < doc.tags.length; i++) {
575
+ const t = doc.tags[i];
576
+ if (t.close) {
577
+ depth--;
578
+ if (depth === 0) break;
579
+ if (depth === 1 && childOpen) {
580
+ children.push({ name: childOpen.name, open: doc.src.slice(childOpen.pos, childOpen.end), html: doc.src.slice(childStart, t.end), idx: childIdx });
581
+ childOpen = null;
582
+ }
583
+ continue;
584
+ }
585
+ if (depth === 1) {
586
+ if (t.selfClosing) {
587
+ children.push({ name: t.name, open: doc.src.slice(t.pos, t.end), html: doc.src.slice(t.pos, t.end), idx: i });
588
+ } else {
589
+ childStart = t.pos; childOpen = t; childIdx = i;
590
+ }
591
+ }
592
+ if (!t.selfClosing) depth++;
593
+ }
594
+ return children;
595
+ }
596
+
597
+ function ancestorMap(doc) {
598
+ if (doc._anc) return doc._anc;
599
+ const map = new Map();
600
+ const stack = [];
601
+ for (let i = 0; i < doc.tags.length; i++) {
602
+ const t = doc.tags[i];
603
+ if (t.close) { stack.pop(); continue; }
604
+ map.set(i, stack.slice());
605
+ if (!t.selfClosing) stack.push(t.name);
606
+ }
607
+ doc._anc = map;
608
+ return map;
609
+ }
610
+
611
+ function plainText(html) {
612
+ return html.replace(/<[^>]*>/g, ' ')
613
+ .replace(/&middot;|&#0?183;|&#x0?b7;/gi, '·').replace(/&bull;|&#8226;|&#x2022;/gi, '•').replace(/&mdash;|&#8212;|&#x2014;/gi, '\u2014').replace(/&ndash;|&#8211;|&#x2013;/gi, '\u2013')
614
+ .replace(/&rarr;|&#8594;|&#x2192;|&xrarr;|&#10230;/gi, '\u2192').replace(/&raquo;|&#187;|&#xbb;/gi, '\u00bb').replace(/&rsaquo;|&#8250;|&#x203a;/gi, '\u203a')
615
+ .replace(/&nbsp;|&#160;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
616
+ .replace(/&rsquo;|&lsquo;|&#8217;|&#8216;|&#x2019;|&#x2018;/gi, '\u2019').replace(/&rdquo;|&ldquo;|&#8220;|&#8221;/gi, '"')
617
+ .replace(/&#\d+;|&[a-z]+;/gi, ' ')
618
+ .replace(/\s+/g, ' ').trim();
619
+ }
620
+
621
+ function innerOf(doc, idx) {
622
+ const open = doc.tags[idx];
623
+ if (open.close || open.selfClosing) return null;
624
+ let depth = 1;
625
+ for (let i = idx + 1; i < doc.tags.length; i++) {
626
+ const t = doc.tags[i];
627
+ if (t.selfClosing) continue;
628
+ if (t.close) { depth--; if (depth === 0) return { inner: doc.src.slice(open.end, t.pos), endPos: t.pos }; }
629
+ else depth++;
630
+ }
631
+ return { inner: doc.src.slice(open.end), endPos: doc.src.length };
632
+ }
633
+
634
+ function parseNum(s) { const v = parseFloat(s); return Number.isNaN(v) ? null : v; }
635
+
636
+ function parseColor(raw) {
637
+ if (!raw) return null;
638
+ const v = raw.trim().toLowerCase();
639
+ if (v === 'white') return { r: 1, g: 1, b: 1, alpha: 1 };
640
+ if (v === 'black') return { r: 0, g: 0, b: 0, alpha: 1 };
641
+ if (v === 'transparent') return { r: 0, g: 0, b: 0, alpha: 0 };
642
+ let m;
643
+ if ((m = /^#([0-9a-f]{3,8})$/.exec(v))) {
644
+ const h = m[1];
645
+ if (h.length === 3 || h.length === 4) {
646
+ const r = parseInt(h[0] + h[0], 16) / 255, g = parseInt(h[1] + h[1], 16) / 255, b = parseInt(h[2] + h[2], 16) / 255;
647
+ const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
648
+ return { r, g, b, alpha: a };
649
+ }
650
+ if (h.length === 6 || h.length === 8) {
651
+ const r = parseInt(h.slice(0, 2), 16) / 255, g = parseInt(h.slice(2, 4), 16) / 255, b = parseInt(h.slice(4, 6), 16) / 255;
652
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
653
+ return { r, g, b, alpha: a };
654
+ }
655
+ return null;
656
+ }
657
+ if ((m = /^rgba?\(([^)]+)\)$/.exec(v))) {
658
+ const parts = m[1].split('/');
659
+ const nums = parts[0].split(/[\s,]+/).filter(Boolean);
660
+ if (nums.length < 3) return null;
661
+ const chan = (s) => s.endsWith('%') ? parseNum(s) / 100 : parseNum(s) / 255;
662
+ let alpha = 1;
663
+ if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);
664
+ else if (nums.length === 4) alpha = nums[3].endsWith('%') ? parseNum(nums[3]) / 100 : parseNum(nums[3]);
665
+ const [r, g, b] = nums.slice(0, 3).map(chan);
666
+ if ([r, g, b].some((x) => x == null)) return null;
667
+ return { r, g, b, alpha: alpha ?? 1 };
668
+ }
669
+ if ((m = /^hsla?\(([^)]+)\)$/.exec(v))) {
670
+ const parts = m[1].split('/');
671
+ const nums = parts[0].split(/[\s,]+/).filter(Boolean);
672
+ if (nums.length < 3) return null;
673
+ const h = parseNum(nums[0]);
674
+ const s = parseNum(nums[1]) / 100, l = parseNum(nums[2]) / 100;
675
+ let alpha = 1;
676
+ if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);
677
+ else if (nums.length === 4) alpha = nums[3].endsWith('%') ? parseNum(nums[3]) / 100 : parseNum(nums[3]);
678
+ if ([h, s, l].some((x) => x == null)) return null;
679
+ const c = (1 - Math.abs(2 * l - 1)) * s;
680
+ const hp = ((h % 360) + 360) % 360 / 60;
681
+ const x = c * (1 - Math.abs((hp % 2) - 1));
682
+ let rgb = [0, 0, 0];
683
+ if (hp < 1) rgb = [c, x, 0]; else if (hp < 2) rgb = [x, c, 0];
684
+ else if (hp < 3) rgb = [0, c, x]; else if (hp < 4) rgb = [0, x, c];
685
+ else if (hp < 5) rgb = [x, 0, c]; else rgb = [c, 0, x];
686
+ const mAdd = l - c / 2;
687
+ return { r: rgb[0] + mAdd, g: rgb[1] + mAdd, b: rgb[2] + mAdd, alpha: alpha ?? 1 };
688
+ }
689
+ if ((m = /^oklch\(([^)]+)\)$/.exec(v))) {
690
+ const parts = m[1].split('/');
691
+ const nums = parts[0].split(/\s+/).filter(Boolean);
692
+ if (nums.length < 3) return null;
693
+ let L = parseNum(nums[0]);
694
+ if (nums[0].endsWith('%')) L = L / 100;
695
+ const C = parseNum(nums[1]);
696
+ let H = nums[2] === 'none' ? 0 : parseNum(nums[2]);
697
+ let alpha = 1;
698
+ if (parts[1] != null) alpha = parts[1].trim().endsWith('%') ? parseNum(parts[1]) / 100 : parseNum(parts[1]);
699
+ if (L == null || C == null || H == null) return null;
700
+ const rgb = oklchToSrgb(L, C, H);
701
+ return { ...rgb, alpha: alpha ?? 1, okl: { L, C, H } };
702
+ }
703
+ return null;
704
+ }
705
+
706
+ function oklchToSrgb(L, C, H) {
707
+ const hr = (H * Math.PI) / 180;
708
+ const a = C * Math.cos(hr), b = C * Math.sin(hr);
709
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
710
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
711
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
712
+ const l = l_ ** 3, mm = m_ ** 3, s = s_ ** 3;
713
+ const lin = [
714
+ 4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * s,
715
+ -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * s,
716
+ -0.0041960863 * l - 0.7034186147 * mm + 1.7076147010 * s,
717
+ ].map((c) => Math.min(1, Math.max(0, c)));
718
+ const enc = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
719
+ return { r: enc(lin[0]), g: enc(lin[1]), b: enc(lin[2]) };
720
+ }
721
+
722
+ function srgbToOklch(col) {
723
+ const dec = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
724
+ const r = dec(col.r), g = dec(col.g), b = dec(col.b);
725
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
726
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
727
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
728
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
729
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
730
+ const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
731
+ const C = Math.sqrt(a * a + b2 * b2);
732
+ let H = (Math.atan2(b2, a) * 180) / Math.PI;
733
+ if (H < 0) H += 360;
734
+ return { L, C, H };
735
+ }
736
+
737
+ function luminance(col) {
738
+ const dec = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
739
+ return 0.2126 * dec(col.r) + 0.7152 * dec(col.g) + 0.0722 * dec(col.b);
740
+ }
741
+ function contrastRatio(a, b) {
742
+ const la = luminance(a), lb = luminance(b);
743
+ const hi = Math.max(la, lb), lo = Math.min(la, lb);
744
+ return (hi + 0.05) / (lo + 0.05);
745
+ }
746
+
747
+ const TOKEN_SEL = /^(?:html|:root)?(?:\[data-(?:theme|mode|scheme)[^\]]*\]|:root|:not\([^)]*\))*$/i;
748
+ function isTokenRule(rule) {
749
+ if (rule.selector === '@font-face' || rule.selector === '@theme' || basename(rule.file).toLowerCase() === 'tokens.css') return true;
750
+ return rule.selector.split(',').every((p) => { const t = p.trim(); return t && TOKEN_SEL.test(t); });
751
+ }
752
+
753
+ function buildTokens(rules) {
754
+ const tokens = {};
755
+ for (const r of rules) {
756
+ if (!isTokenRule(r)) continue;
757
+ for (const d of r.decls) if (d.prop.startsWith('--')) tokens[d.prop] = d.value;
758
+ }
759
+ return tokens;
760
+ }
761
+
762
+ function resolveVars(value, tokens, local = null, depth = 0) {
763
+ if (!value || depth > 5 || !value.includes('var(')) return value;
764
+ const out = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (m0, name, fb) => {
765
+ const v = (local && local[name] != null) ? local[name] : tokens[name];
766
+ if (v != null) return v;
767
+ if (fb != null) return fb.trim();
768
+ return m0;
769
+ });
770
+ if (out === value) return out;
771
+ return resolveVars(out, tokens, local, depth + 1);
772
+ }
773
+
774
+ // A family's identity ignores spaces, hyphens and underscores, so "GeistSans" and "Geist Sans" are one face
775
+ const famKey = (f) => String(f || '').replace(/[\s_-]+/g, '');
776
+ function firstFamily(value) {
777
+ const first = value.split(',')[0].trim().replace(/^['"]|['"]$/g, '').toLowerCase();
778
+ return first;
779
+ }
780
+
781
+ function pxOf(value) {
782
+ const m = /^(-?\d+(?:\.\d+)?)(px|rem|em)$/.exec(value.trim());
783
+ if (!m) return null;
784
+ const n = parseFloat(m[1]);
785
+ return m[2] === 'px' ? n : n * 16;
786
+ }
787
+
788
+ const rel = (f) => f.startsWith(process.cwd()) ? f.slice(process.cwd().length + 1) : f;
789
+ const trunc = (s, n = 72) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
790
+
791
+ const PAGE_ONLY = new Set(['3', '8', '9', '25', '28', '32', '34', '42', '44', '46b', '47b', '47c', '49', '50', '54', '55', '56', '58', '59', '65', '66', '68', '69', '71', '77', '78', '79', '80', '81', '82', '83', 'F7', 'F8', 'F15', 'F16', 'F20']);
792
+ const gateNumber = (id) => (/^(\d{1,2})/.exec(String(id)) || [])[1] || null;
793
+ const pageOnly = (id) => PAGE_ONLY.has(String(id)) || PAGE_ONLY.has(gateNumber(id));
794
+ const APP_SKIP = new Set(['1', '6', '8', '9', '32', '42', '44', '46b', '58', '59', '66', '68', '77', '78', '79', '81', '82', '83', '85', 'F8', 'F15', 'F20']);
795
+ const appSkip = (id) => APP_SKIP.has(String(id)) || APP_SKIP.has(gateNumber(id));
796
+
797
+ const TIERS = new Map(Object.entries({
798
+ '2': 'floor', '4': 'floor', '10': 'floor', '15': 'floor', '17': 'floor', '18': 'floor', '19': 'floor',
799
+ '20': 'floor', '26': 'floor', '27': 'floor', '28': 'floor', '30': 'floor', '33': 'floor', '34': 'floor',
800
+ '39': 'floor', '40': 'floor', '41': 'floor', '46': 'floor', '48': 'floor', '49': 'floor', '50': 'floor',
801
+ '53': 'floor', '54': 'floor', '56': 'floor', '58': 'floor', '62': 'floor', '63': 'floor', '67': 'floor', '70': 'floor',
802
+ '1': 'reflex', '3': 'reflex', '6': 'reflex', '7': 'reflex', '8': 'reflex', '9': 'reflex', '11': 'reflex',
803
+ '12': 'reflex', '16': 'reflex', '22': 'reflex', '23': 'reflex', '24': 'reflex', '25': 'reflex', '29': 'reflex',
804
+ '37': 'reflex', '38': 'reflex', '42': 'reflex', '44': 'reflex', '47': 'reflex', '55': 'reflex', '59': 'reflex',
805
+ '64': 'reflex', '65': 'reflex', '66': 'reflex', '68': 'reflex', '69': 'reflex',
806
+ '32': 'ledger',
807
+ '2a': 'floor', '2b': 'reflex', '6a': 'floor', '6b': 'reflex', '7a': 'floor', '7b': 'reflex',
808
+ '10a': 'floor', '10b': 'reflex', '24a': 'floor', '24b': 'reflex', '26a': 'floor', '26b': 'reflex',
809
+ '28a': 'floor', '28b': 'reflex', '30a': 'floor', '30b': 'reflex', '38i': 'floor', '38ii': 'reflex', '38iii': 'reflex',
810
+ '39a': 'floor', '39b': 'reflex', '44a': 'reflex', '44b': 'floor', '44c': 'reflex', '44d': 'floor',
811
+ '46a': 'floor', '46b': 'reflex', '47a': 'floor', '47b': 'reflex', '47c': 'reflex', '49a': 'floor', '49b': 'reflex',
812
+ '55a': 'floor', '55b': 'reflex', '71': 'reflex', '72': 'reflex', '73': 'floor', '74': 'reflex', '75': 'reflex', '76': 'floor',
813
+ '77': 'reflex', '78': 'reflex', '79': 'reflex', '80': 'reflex', '81': 'reflex', '82': 'reflex', '83': 'reflex', '84': 'reflex', '85': 'reflex',
814
+ }));
815
+
816
+ const HERO_SKIP = new Set(['8', '9', '32', '59', '76', '77', '78', 'F8', 'F15']);
817
+ function baseGate(id) {
818
+ const s = String(id).trim();
819
+ if (/^F\d+$/i.test(s)) return s.toUpperCase();
820
+ const m = /^(\d{1,2}(?:i{1,3}|[a-z])?)/.exec(s);
821
+ return m ? m[1] : s;
822
+ }
823
+ function tierOf(gate) {
824
+ const exact = TIERS.get(String(gate).trim());
825
+ if (exact) return exact;
826
+ const b = baseGate(gate);
827
+ if (/^F\d+$/.test(b)) return 'finish';
828
+ return TIERS.get(b) || 'floor';
829
+ }
830
+
831
+ function fontFamilyCount(ctx) {
832
+ const fams = new Set();
833
+ for (const [prop, val] of Object.entries(ctx.tokens || {})) {
834
+ if (/^--font-/.test(prop)) fams.add(famKey(firstFamily(resolveVars(val, ctx.tokens))));
835
+ }
836
+ return fams.size;
837
+ }
838
+ const GUARDS = {
839
+ '1': (ctx) => fontFamilyCount(ctx) >= 2 || 'fewer than two font families are declared',
840
+ '2b': (ctx) => {
841
+ for (const { r, grad } of gradientTextRules(ctx)) {
842
+ if (!grad) return `${r.selector} clips text to a background that is not a gradient ramp`;
843
+ if (/#[0-9a-f]{3,8}\b|oklch\(|rgba?\(|hsla?\(/i.test(grad.value)) return `${r.selector}: the ramp stops are literals, not tokens`;
844
+ const hues = gradientHues(grad.value, ctx);
845
+ if (hues.length >= 2 && Math.max(...hues) - Math.min(...hues) > 40) return `${r.selector}: ramp spans ${Math.round(Math.max(...hues) - Math.min(...hues))} degrees of hue`;
846
+ }
847
+ for (const it of twGroup(ctx).items) {
848
+ const g = twGradient(ctx, it.info);
849
+ if (!g || !g.text) continue;
850
+ if (it.info.stops.some((s) => s.kind !== 'token')) return `${twWhere(it)}: the ramp stops are palette or literal classes, not tokens`;
851
+ const hues = it.info.stops.map((s) => s.hue).filter((h) => h != null);
852
+ if (hues.length >= 2 && Math.max(...hues) - Math.min(...hues) > 40) return `${twWhere(it)}: ramp spans ${Math.round(Math.max(...hues) - Math.min(...hues))} degrees of hue`;
853
+ }
854
+ return true;
855
+ },
856
+ '7': (ctx) => /surface:\s*absolute/i.test(ctx.stampText) || 'the stamp does not declare surface: absolute',
857
+ '23': (ctx) => /posture:\s*(restrained|committed|full-palette|drenched)/i.test(ctx.stampText)
858
+ || 'the stamp declares no colour posture',
859
+ '37': (ctx) => fontFamilyCount(ctx) <= 4 || 'five or more families is past the backstop',
860
+ '26b': (ctx) => !ctx.docs.some((d) => d.tags.some((t) => !t.close && /aria-disabled/i.test(t.attrs))) || 'aria-disabled appears in the markup',
861
+ '38': () => 'name the clause: 38ii (italic display) or 38iii (accented word)',
862
+ '38ii': (ctx) => /font-synthesis\s*:\s*none/i.test(ctx.allCss) || 'font-synthesis: none is not declared',
863
+ '38iii': (ctx) => accentedWordInHero(ctx),
864
+ '42': (ctx) => (/(^|[·*])\s*nav:\s*\S/im.test(ctx.stampText) && /(^|[·*])\s*footer:\s*\S/im.test(ctx.stampText)) || 'the stamp has no nav: and footer: fields naming the chrome',
865
+ '64': (ctx) => monoIsMaterial(ctx) || 'none of --font-display, --font-body or --font-outlier is a monospace face',
866
+ '65': (ctx) => /by request|pinned|user named/i.test(ctx.stampText) || 'the stamp does not say the theme was asked for by name',
867
+ '44': (ctx) => /(^|[·*])\s*hero:\s*\S/im.test(ctx.stampText) || 'the stamp names no hero: posture',
868
+ };
869
+
870
+ const WAIVER_MAX = 3;
871
+
872
+ const WAIVER_FILLER = /\b(a|an|the|is|it|this|that|was|are|and|but|so|very|just|really|here|quite|more|most)\b/gi;
873
+ const WAIVER_BOILERPLATE_PHRASES = [
874
+ 'design choice', 'design decision', 'intentionally', 'intentional', 'by design',
875
+ 'looks better', 'for the aesthetic', 'aesthetic', 'necessary here', 'necessary',
876
+ 'the prompt requires it', 'prompt requires', 'the brief requires it', 'brief requires', 'on purpose', 'deliberate', 'deliberately',
877
+ 'it works', 'works better', 'needed', 'required', 'fine', 'ok', 'okay',
878
+ ];
879
+ function reasonIsBoilerplate(reason) {
880
+ let r = reason.toLowerCase();
881
+ for (const p of WAIVER_BOILERPLATE_PHRASES) r = r.split(p).join(' ');
882
+ r = r.replace(WAIVER_FILLER, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
883
+ return r.length < 12;
884
+ }
885
+
886
+ let WAIVED = new Set();
887
+ let ANSWERED = new Set();
888
+ let DISMISSED = new Set();
889
+ let VERBATIM = false;
890
+ let APPSCREEN = false;
891
+ let LAYOUT_KEPT = false;
892
+ const STRUCTURAL = new Set(['6b', '8', '9', '32', '42', '44a', '44c', '58', '77', '79']);
893
+ const SPACING = new Set(['24b', '59', '78']);
894
+ const COPY_GATES = new Set(['19', '58', '69']);
895
+
896
+ const findings = [];
897
+ function report(gate, grade, file, line, evidence, fix) {
898
+ const g = String(gate);
899
+ if (opts.before && g === '20') return;
900
+ if (opts.scope === 'component' && pageOnly(g)) return;
901
+ if ((opts.scope === 'app' || APPSCREEN) && appSkip(g)) return;
902
+ if (opts.stage === 'hero' && HERO_SKIP.has(String(g))) return;
903
+ const tier = tierOf(g);
904
+ const b = baseGate(g);
905
+ const waived = WAIVED.has(b) || WAIVED.has(gateNumber(b));
906
+ if (VERBATIM && COPY_GATES.has(gateNumber(b)) && (grade === 'FAIL' || grade === 'WARN')) grade = 'NOTE';
907
+ const owned = LAYOUT_KEPT === 'full' ? gateNumber(b) === '58' : STRUCTURAL.has(b) || STRUCTURAL.has(gateNumber(b)) || (LAYOUT_KEPT === 'polish' && SPACING.has(b));
908
+ if (LAYOUT_KEPT && owned && grade !== 'NOTE') grade = 'NOTE';
909
+ if (grade === 'FAIL') {
910
+ if (tier === 'ledger') grade = 'NOTE';
911
+ else if (tier === 'reflex') grade = waived ? 'WAIVED' : 'REFLEX';
912
+ }
913
+ if (tier === 'finish' && grade !== 'FAIL' && ANSWERED.has(b)) grade = 'ANSWERED';
914
+ if (grade === 'WARN' && (DISMISSED.has(b) || DISMISSED.has(gateNumber(b)))) grade = 'DISMISSED';
915
+ if (grade === 'WARN' && tier === 'reflex' && waived) grade = 'WAIVED';
916
+ findings.push({ gate: g, grade, tier, file: rel(file), line: line || 1, evidence: trunc(String(evidence), g === 'kept' ? 400 : 120), fix });
917
+ }
918
+
919
+ function applyWaivers(ctx, dir) {
920
+ WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set();
921
+ VERBATIM = /\bcopy:\s*verbatim\b/i.test(ctx.stampText);
922
+ APPSCREEN = /\bshape:\s*app screen\b/i.test(ctx.stampText);
923
+ LAYOUT_KEPT = (/\bredesign:\s*(polish|restyle|full)\b/i.exec(ctx.stampText) || [])[1]?.toLowerCase() || false;
924
+ const re = /\/\*\s*Hallmark\s*·\s*waive\s+(\d{1,2}[a-z]{0,3}|F\d+)\s*·\s*([^·*]+?)\s*·\s*([^*]+?)\s*\*\//gi;
925
+ const claims = [...ctx.stampText.matchAll(re)].map((m) => ({
926
+ id: m[1], evidence: m[2].trim(), reason: m[3].trim(), raw: m[0],
927
+ }));
928
+
929
+ const wellFormed = new Set(claims.map((c) => c.raw));
930
+ const malformed = [...ctx.stampText.matchAll(/\/\*[^*]*Hallmark[^*]*·\s*waive\b[^*]*\*\//gi)]
931
+ .map((m) => m[0]).filter((raw) => !wellFormed.has(raw));
932
+ const reasons = new Map();
933
+ for (const c of claims) {
934
+ const key = c.reason.toLowerCase();
935
+ reasons.set(key, (reasons.get(key) || 0) + 1);
936
+ }
937
+
938
+ const pageWords = new Set(
939
+ (ctx.stampText.replace(/\/\*\s*Hallmark\s*·\s*(?:waive|answered|dismissed)\b[^*]*\*\//gi, ' ') + ' ' + ctx.docs.map((d) => d.text).join(' '))
940
+ .toLowerCase().match(/[a-z]{4,}/g) || []
941
+ );
942
+ const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file || dir;
943
+ const rejectAt = (c, why) =>
944
+ findings.push({
945
+ gate: baseGate(c.id), grade: 'WAIVER', tier: tierOf(c.id), file: rel(file), line: 1,
946
+ evidence: trunc(`waiver on ${c.id} REJECTED (${why})`),
947
+ fix: 'Fix the gate, or satisfy the guard and say why in the page\'s own words',
948
+ });
949
+
950
+ for (const raw of malformed) {
951
+ findings.push({
952
+ gate: 'waive', grade: 'WAIVER', tier: 'reflex', file: rel(file), line: 1,
953
+ evidence: trunc(`malformed waiver, ignored: ${raw.replace(/\s+/g, ' ')}`),
954
+ fix: 'Three · -separated fields: /* Hallmark · waive <gate> · <guard evidence> · <reason> */',
955
+ });
956
+ }
957
+ for (const c of claims) {
958
+ const b = baseGate(c.id);
959
+ const tier = tierOf(c.id);
960
+ if (tier !== 'reflex') {
961
+ rejectAt(c, tier === 'ledger' ? 'a Ledger note is bookkeeping, nothing to waive'
962
+ : tier === 'finish' ? 'Finish findings are answered, not waived'
963
+ : 'Floor is never waivable');
964
+ continue;
965
+ }
966
+ if (/^\d+$/.test(c.id) && [...TIERS.keys()].some((k) => k !== c.id && gateNumber(k) === c.id)) { rejectAt(c, `name the clause (${[...TIERS.keys()].filter((k) => k !== c.id && gateNumber(k) === c.id).join(' or ')})`); continue; }
967
+ if (WAIVED.has(b)) { rejectAt(c, 'one waiver per gate'); continue; }
968
+ if (c.reason.length < 24) { rejectAt(c, 'reason under 24 characters'); continue; }
969
+ if (reasonIsBoilerplate(c.reason)) { rejectAt(c, 'boilerplate reason'); continue; }
970
+ if (reasons.get(c.reason.toLowerCase()) > 1) { rejectAt(c, 'reason reused on another waiver'); continue; }
971
+ const words = c.reason.toLowerCase().match(/[a-z]{4,}/g) || [];
972
+ if (!words.some((w) => pageWords.has(w))) { rejectAt(c, 'reason shares no word with the page'); continue; }
973
+ const guard = GUARDS[b] || GUARDS[gateNumber(b)];
974
+ const verdict = guard ? guard(ctx) : true;
975
+ if (verdict !== true) { rejectAt(c, `guard: ${verdict}`); continue; }
976
+ WAIVED.add(b);
977
+ }
978
+
979
+ if (WAIVED.size > WAIVER_MAX) {
980
+ findings.push({
981
+ gate: 'waive', grade: 'NOTE', tier: 'ledger', file: rel(file), line: 1,
982
+ evidence: trunc(`${WAIVED.size} reflex gates waived (${[...WAIVED].join(', ')}): that is a house style, not a set of exceptions`),
983
+ fix: 'Lock it into a design.md so later pages inherit it instead of re-arguing it',
984
+ });
985
+ }
986
+
987
+ const ansRe = /\/\*\s*Hallmark\s*·\s*answered\s+(\d{1,2}[a-z]{0,3}|F\d+)\s*·\s*([^*]+?)\s*\*\//gi;
988
+ for (const m of ctx.stampText.matchAll(ansRe)) {
989
+ const id = baseGate(m[1]);
990
+ const reason = m[2].trim();
991
+ const bad = tierOf(id) !== 'finish' ? 'only Finish findings are answered; Reflex is waived and Floor is fixed'
992
+ : reason.length < 24 ? 'reason under 24 characters'
993
+ : reasonIsBoilerplate(reason) ? 'boilerplate reason' : null;
994
+ if (bad) {
995
+ findings.push({
996
+ gate: id, grade: 'WAIVER', tier: tierOf(id), file: rel(file), line: 1,
997
+ evidence: trunc(`answer on ${m[1]} REJECTED (${bad})`),
998
+ fix: 'Two · -separated fields: /* Hallmark · answered <check> · <reason> */',
999
+ });
1000
+ continue;
1001
+ }
1002
+ ANSWERED.add(id);
1003
+ }
1004
+
1005
+ const disRe = /\/\*\s*Hallmark\s*·\s*dismissed\s+(\d{1,2}[a-z]{0,3}|F\d+)\s*·\s*([^*]+?)\s*\*\//gi;
1006
+ for (const m of ctx.stampText.matchAll(disRe)) {
1007
+ const reason = m[2].trim();
1008
+ const bad = reason.length < 24 ? 'reason under 24 characters' : reasonIsBoilerplate(reason) ? 'boilerplate reason' : null;
1009
+ if (bad) {
1010
+ findings.push({ gate: baseGate(m[1]), grade: 'WAIVER', tier: tierOf(m[1]), file: rel(file), line: 1, evidence: trunc(`dismissal of ${m[1]} REJECTED (${bad})`), fix: 'Two · -separated fields: /* Hallmark · dismissed <gate> · <what you checked and why the warning is wrong here> */' });
1011
+ continue;
1012
+ }
1013
+ DISMISSED.add(baseGate(m[1]));
1014
+ }
1015
+ }
1016
+
1017
+ const GRADE_RANK = { FAIL: 0, REFLEX: 1, WAIVER: 2, WARN: 3, WAIVED: 4, ANSWERED: 5, DISMISSED: 6, NOTE: 7 };
1018
+
1019
+ function gateSortKey(g) {
1020
+ const f = /^F(\d+)$/.exec(g);
1021
+ if (f) return 900 + Number(f[1]);
1022
+ const n = parseFloat(g);
1023
+ return Number.isNaN(n) ? 999 : n;
1024
+ }
1025
+
1026
+ function selectorIsInteractive(sel) {
1027
+ return /(^|[\s,>+~(])(a|button|input|select|textarea|summary)\b|\.(btn|button|cta|link|tab|chip|control)/i.test(sel);
1028
+ }
1029
+ const HEADING_SEL = /(^|[\s,>+~(])h[1-6]\b|__title|__display|__headline|hero__(title|display|headline|h\d)|wordmark|masthead|display/i;
1030
+ const GENERIC_FAMILIES = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded', 'math', 'emoji', 'fangsong', 'inherit', 'initial', 'unset']);
1031
+
1032
+ function mergedDeclsBySelector(ctx) {
1033
+ if (ctx._merged) return ctx._merged;
1034
+ const map = new Map();
1035
+ for (const r of ctx.rules) {
1036
+ if (r.keyframes || r.selector === '@font-face') continue;
1037
+ const key = r.selector + '@@' + (r.media || '');
1038
+ if (!map.has(key)) map.set(key, { selector: r.selector, media: r.media, decls: [], file: r.file, line: r.line });
1039
+ map.get(key).decls.push(...r.decls);
1040
+ }
1041
+ ctx._merged = [...map.values()];
1042
+ return ctx._merged;
1043
+ }
1044
+
1045
+ function styleIndex(ctx) {
1046
+ if (ctx._styleIdx) return ctx._styleIdx;
1047
+ const entries = [];
1048
+ for (const r of ctx.rules) {
1049
+ if (r.keyframes || r.selector === '@font-face') continue;
1050
+ if (r.media && /max-width/i.test(r.media)) continue;
1051
+ for (const part of r.selector.split(',')) {
1052
+ const compound = part.trim().split(/[\s>+~]+/).filter(Boolean).pop();
1053
+ if (!compound) continue;
1054
+ const cleaned = compound.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\[[^\]]*\]/g, '');
1055
+ const classes = (cleaned.match(/\.[a-zA-Z_][\w-]*/g) || []).map((c) => c.slice(1));
1056
+ const tagM = /^([a-zA-Z][\w-]*)/.exec(cleaned);
1057
+ const tag = tagM ? tagM[1].toLowerCase() : null;
1058
+ if (!classes.length && !tag) continue;
1059
+ entries.push({ classes, tag, decls: r.decls, state: /:(hover|active|focus|focus-visible|focus-within|disabled|checked|visited|target)\b|::?(before|after|marker|placeholder|selection|backdrop)\b/i.test(compound) });
1060
+ }
1061
+ }
1062
+ ctx._styleIdx = entries;
1063
+ return entries;
1064
+ }
1065
+
1066
+ function styleFor(ctx, classAttr, tagName, restingOnly = false) {
1067
+ const have = new Set(String(classAttr || '').split(/\s+/).filter(Boolean));
1068
+ const decls = [];
1069
+ for (const e of styleIndex(ctx)) {
1070
+ if (restingOnly && e.state) continue;
1071
+ if (e.tag && e.tag !== tagName) continue;
1072
+ if (!e.classes.every((c) => have.has(c))) continue;
1073
+ decls.push(...e.decls);
1074
+ }
1075
+ return decls;
1076
+ }
1077
+
1078
+ function declValue(ctx, decls, prop) {
1079
+ let v = null;
1080
+ for (const d of decls) if (d.prop === prop) v = d.value;
1081
+ return v == null ? null : resolveVars(v, ctx.tokens);
1082
+ }
1083
+
1084
+ function fontPxOf(ctx, decls) {
1085
+ const v = declValue(ctx, decls, 'font-size');
1086
+ if (!v) return null;
1087
+ const clamped = /clamp\(\s*([^,]+),/.exec(v);
1088
+ return pxOf(clamped ? clamped[1].trim() : v);
1089
+ }
1090
+
1091
+ function trackingEmOf(ctx, decls, fontPx) {
1092
+ const v = declValue(ctx, decls, 'letter-spacing');
1093
+ if (!v) return null;
1094
+ const m = /^(-?\d*\.?\d+)em$/.exec(v.trim());
1095
+ if (m) return parseFloat(m[1]);
1096
+ const px = pxOf(v);
1097
+ if (px == null || !fontPx) return null;
1098
+ return px / fontPx;
1099
+ }
1100
+
1101
+ function check1(ctx) {
1102
+ const banned = new Set(['inter', 'roboto', 'open sans', 'poppins', 'lato', 'system-ui', '-apple-system', 'arial', 'helvetica', 'helvetica neue', 'segoe ui']);
1103
+ const seen = new Set();
1104
+ const flag = (fam, file, line, where) => {
1105
+ if ([...banned].some((b) => famKey(b) === famKey(fam)) && !seen.has(fam + where)) {
1106
+ seen.add(fam + where);
1107
+ report(1, 'FAIL', file, line, `display font "${fam}" (${where})`, 'Pick a display face from the typography catalog');
1108
+ }
1109
+ };
1110
+ for (const [prop, val] of Object.entries(ctx.tokens)) {
1111
+ if (/^--font-(display|heading|title|hero)/.test(prop)) {
1112
+ const tr = ctx.rules.find((r) => isTokenRule(r) && r.decls.some((d) => d.prop === prop));
1113
+ flag(firstFamily(resolveVars(val, ctx.tokens)), tr ? tr.file : ctx.rules[0]?.file || '?', tr ? tr.decls.find((d) => d.prop === prop)?.line : 1, prop);
1114
+ }
1115
+ }
1116
+ for (const r of ctx.rules) {
1117
+ if (isTokenRule(r) || !HEADING_SEL.test(r.selector)) continue;
1118
+ for (const d of r.decls) {
1119
+ if (d.prop === 'font-family') flag(firstFamily(resolveVars(d.value, ctx.tokens)), r.file, d.line, r.selector);
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ const hueIn = (h, lo, hi) => (lo <= hi ? h >= lo && h <= hi : h >= lo || h <= hi);
1125
+ function gradientHues(value, ctx) {
1126
+ const resolved = resolveVars(value, ctx.tokens);
1127
+ const stops = [];
1128
+ const colRe = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
1129
+ let cm;
1130
+ while ((cm = colRe.exec(resolved))) {
1131
+ const c = parseColor(cm[0]);
1132
+ if (c && c.alpha > 0.05) {
1133
+ const ok = c.okl || srgbToOklch(c);
1134
+ if (ok.C > 0.04) stops.push(ok.H);
1135
+ }
1136
+ }
1137
+ return stops;
1138
+ }
1139
+ function paperIsDark(ctx) {
1140
+ const p = parseColor(resolveVars(ctx.tokens['--color-paper'] || '', ctx.tokens));
1141
+ return !!p && (p.okl || srgbToOklch(p)).L < 0.4;
1142
+ }
1143
+ function bannedGradient(stops, ctx) {
1144
+ const purple = stops.some((h) => hueIn(h, 265, 330));
1145
+ const blue = stops.some((h) => hueIn(h, 200, 264));
1146
+ const pink = stops.some((h) => hueIn(h, 331, 25));
1147
+ const cyan = stops.some((h) => hueIn(h, 170, 230));
1148
+ if (purple && blue) return 'purple-to-blue';
1149
+ if (purple && pink) return 'purple-to-pink';
1150
+ if (cyan && paperIsDark(ctx)) return 'cyan-on-dark';
1151
+ return null;
1152
+ }
1153
+ function gradientTextRules(ctx) {
1154
+ const out = [];
1155
+ for (const r of mergedDeclsBySelector(ctx)) {
1156
+ const clip = r.decls.find((d) => /background-clip/.test(d.prop) && /\btext\b/i.test(d.value));
1157
+ if (!clip) continue;
1158
+ const grad = r.decls.find((d) => /^background(-image)?$/.test(d.prop) && /gradient\(/i.test(d.value));
1159
+ const transparent = r.decls.find((d) => (d.prop === 'color' || d.prop === '-webkit-text-fill-color') && /transparent/i.test(d.value));
1160
+ if (grad || transparent) out.push({ r, clip, grad });
1161
+ }
1162
+ return out;
1163
+ }
1164
+ function withoutPatterns(value) {
1165
+ let out = '', i = 0;
1166
+ const re = /repeating-(?:linear|radial|conic)-gradient\(/gi;
1167
+ let m;
1168
+ while ((m = re.exec(value))) {
1169
+ out += value.slice(i, m.index);
1170
+ let depth = 1, k = m.index + m[0].length;
1171
+ while (k < value.length && depth > 0) { if (value[k] === '(') depth++; else if (value[k] === ')') depth--; k++; }
1172
+ i = k; re.lastIndex = k;
1173
+ }
1174
+ return out + value.slice(i);
1175
+ }
1176
+
1177
+ function check2(ctx) {
1178
+ const textRules = new Set();
1179
+ for (const { r, clip, grad } of gradientTextRules(ctx)) {
1180
+ textRules.add(r.selector);
1181
+ const banned = grad ? bannedGradient(gradientHues(grad.value, ctx), ctx) : null;
1182
+ if (banned) report('2a', 'FAIL', r.file, clip.line, `${banned} gradient text on ${r.selector}`, 'One anchor hue, solid ink');
1183
+ else report('2b', 'FAIL', r.file, clip.line, `gradient text on ${r.selector}`, 'Solid ink; weight or a display face. Guard: a single-hue ramp from two tokens with 40 and 41 passing');
1184
+ }
1185
+ for (const r of ctx.rules) {
1186
+ if (textRules.has(r.selector)) continue;
1187
+ for (const d of r.decls) {
1188
+ if (!/gradient\(/i.test(d.value)) continue;
1189
+ const washes = withoutPatterns(d.value);
1190
+ if (!/gradient\(/i.test(washes)) continue;
1191
+ const banned = bannedGradient(gradientHues(washes, ctx), ctx);
1192
+ if (banned) report('2a', 'FAIL', r.file, d.line, `${banned} gradient on ${r.selector}`, 'One anchor hue; drop the gradient');
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ function check3(ctx) {
1198
+ for (const r of ctx.rules) {
1199
+ for (const d of r.decls) {
1200
+ if (d.prop !== 'grid-template-columns') continue;
1201
+ const v = resolveVars(d.value, ctx.tokens).trim();
1202
+ if (/^repeat\(\s*3\s*,\s*(minmax\(\s*0(px)?\s*,\s*1fr\s*\)|1fr)\s*\)$/.test(v)
1203
+ || /^(1fr|minmax\(\s*0(px)?\s*,\s*1fr\s*\))(\s+(1fr|minmax\(\s*0(px)?\s*,\s*1fr\s*\))){2}$/.test(v)) {
1204
+
1205
+ const classes = (r.selector.match(/\.[a-zA-Z][\w-]*/g) || []).map((c) => c.slice(1));
1206
+ let iconTiles = !ctx.docs.length && !classes.length;
1207
+ for (const doc of ctx.docs) {
1208
+ for (let i = 0; i < doc.tags.length; i++) {
1209
+ const t = doc.tags[i];
1210
+ if (t.close || t.selfClosing) continue;
1211
+ const cls = getAttr(t.attrs, 'class') || '';
1212
+ if (!classes.some((c) => cls.split(/\s+/).includes(c))) continue;
1213
+ const inner = innerOf(doc, i);
1214
+ if (inner && (/class\s*=\s*["'][^"']*icon/i.test(inner.inner) || /<svg\b/i.test(inner.inner)) && /<h[2-6]\b/i.test(inner.inner)) {
1215
+ iconTiles = true;
1216
+ }
1217
+ }
1218
+ }
1219
+ if (iconTiles) {
1220
+ report(3, 'WARN', r.file, d.line, `3 equal icon-tile columns on ${r.selector}`, 'Vary column widths or drop the icon tiles');
1221
+ }
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ function check4(ctx) {
1228
+
1229
+ const cardWord = /(^|-)card(--[\w-]+)?$/i;
1230
+ const containerish = /(grid|list|rail|row|wrap|deck|group|stack|container|section|scroller|track)/i;
1231
+ for (const doc of ctx.docs) {
1232
+ const stack = [];
1233
+ for (const t of doc.tags) {
1234
+ if (t.close) {
1235
+ for (let i = stack.length - 1; i >= 0; i--) {
1236
+ if (stack[i].name === t.name) { stack.splice(i); break; }
1237
+ }
1238
+ continue;
1239
+ }
1240
+ if (t.selfClosing) continue;
1241
+ const cls = getAttr(t.attrs, 'class') || '';
1242
+ const isCard = cls.split(/\s+/).some((c) => { const base = c.split('--')[0]; return base && cardWord.test(base) && !containerish.test(base); });
1243
+ if (isCard && stack.some((s) => s.isCard)) {
1244
+ report(4, 'FAIL', doc.file, t.line, `card nested in card: class="${trunc(cls, 40)}"`, 'Keep one containment layer');
1245
+ }
1246
+ stack.push({ name: t.name, isCard });
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ function check3Stripe(ctx) {
1252
+ for (const r of ctx.rules) {
1253
+ for (const d of r.decls) {
1254
+ if (!/^border-(left|right|inline-start|inline-end)$/.test(d.prop)) continue;
1255
+ const m = /(\d+(?:\.\d+)?)px/.exec(d.value);
1256
+ if (m && parseFloat(m[1]) >= 3 && !/transparent|none/i.test(d.value)) {
1257
+ report(3, 'WARN', r.file, d.line, `side stripe ${d.prop}: ${trunc(d.value, 32)} on ${r.selector}`, 'Hairline all round, none, or an accent square');
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ function heroClasses(ctx) {
1264
+ const out = new Set();
1265
+ for (const doc of ctx.docs) {
1266
+ const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
1267
+ if (h1 < 0) continue;
1268
+ for (let i = h1 - 1; i >= 0; i--) {
1269
+ const t = doc.tags[i];
1270
+ if (t.close || !/^(section|header)$/.test(t.name)) continue;
1271
+ for (const c of (getAttr(t.attrs, 'class') || '').split(/\s+/)) if (c) out.add(c);
1272
+ break;
1273
+ }
1274
+ }
1275
+ return out;
1276
+ }
1277
+ function check6a(ctx) {
1278
+ const heroCls = heroClasses(ctx);
1279
+ const isHero = (sel) => /hero/i.test(sel) || [...heroCls].some((c) => sel.includes('.' + c));
1280
+ const seen = new Set();
1281
+ for (const r of ctx.rules) {
1282
+ if (r.keyframes || isTokenRule(r) || !isHero(r.selector)) continue;
1283
+ for (const d of r.decls) {
1284
+ if (!/^(min-)?height$/.test(d.prop)) continue;
1285
+ const v = resolveVars(d.value, ctx.tokens);
1286
+ if (!/\d\s*vh\b/i.test(v) || seen.has(r.selector)) continue;
1287
+ seen.add(r.selector);
1288
+ report('6a', 'FAIL', r.file, d.line, `hero sized in vh: ${d.prop}: ${trunc(v, 24)} on ${r.selector}`, 'dvh or svh');
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ function check7(ctx) {
1294
+ const props = /^(color|background|background-color|border-color|outline-color)$|^--color-/;
1295
+ for (const r of ctx.rules) {
1296
+ if (r.selector === '@font-face') continue;
1297
+ for (const d of r.decls) {
1298
+ if (!props.test(d.prop)) continue;
1299
+ const resolved = resolveVars(d.value, ctx.tokens).trim();
1300
+ const c = parseColor(resolved);
1301
+ if (!c || c.alpha < 1) continue;
1302
+ const isWhite = c.r > 0.999 && c.g > 0.999 && c.b > 0.999;
1303
+ const isBlack = c.r < 0.001 && c.g < 0.001 && c.b < 0.001;
1304
+ if (isWhite && ctx.genre === 'modern-minimal') continue;
1305
+ if (isWhite || isBlack) {
1306
+
1307
+ const viaToken = d.prop.startsWith('--') || /var\(/.test(d.value);
1308
+ report(viaToken ? '7b' : '7a', 'FAIL', r.file, d.line, `pure ${isBlack ? '#000' : '#fff'}: ${d.prop}: ${trunc(d.value, 28)} (${r.selector})`, 'Tint the base toward the anchor hue');
1309
+ }
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ function check10(ctx) {
1315
+ for (const r of ctx.rules) {
1316
+ for (const d of r.decls) {
1317
+ if (d.prop === 'transition' && /(^|,)\s*all[\s,]|^all$|^all\s/.test(d.value.trim())) {
1318
+ report('10a', 'FAIL', r.file, d.line, `transition: all on ${r.selector}`, 'List the transitioned properties explicitly');
1319
+ }
1320
+ if (d.prop === 'transition-property' && /\ball\b/.test(d.value)) {
1321
+ report('10a', 'FAIL', r.file, d.line, `transition-property: all on ${r.selector}`, 'List the transitioned properties explicitly');
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+
1327
+ function check11(ctx) {
1328
+ const byScale = new Map();
1329
+ for (const r of ctx.rules) {
1330
+ if (!/:hover/.test(r.selector)) continue;
1331
+ for (const d of r.decls) {
1332
+ if (d.prop !== 'transform') continue;
1333
+ const m = /scale\(\s*([\d.]+)\s*\)/.exec(d.value);
1334
+ if (m && parseFloat(m[1]) !== 1) {
1335
+ const k = m[1];
1336
+ if (!byScale.has(k)) byScale.set(k, []);
1337
+ byScale.get(k).push({ sel: r.selector, file: r.file, line: d.line });
1338
+ }
1339
+ }
1340
+ }
1341
+ for (const [k, list] of byScale) {
1342
+ const distinct = new Set(list.map((x) => x.sel));
1343
+ if (distinct.size >= 2) {
1344
+ report(11, 'WARN', list[0].file, list[0].line, `hover scale(${k}) on ${distinct.size} selectors`, 'One hover signal per element');
1345
+ }
1346
+ }
1347
+ for (const r of ctx.rules) {
1348
+ if (!/:hover/.test(r.selector) || !/(^|[\s>+~,])(img|picture|figure|video)\b|__(img|image|photo|thumb|cover)\b|\.(img|image|photo|thumb|cover)\b/i.test(r.selector)) continue;
1349
+ const t = r.decls.find((d) => d.prop === 'transform' && /scale|rotate/.test(d.value));
1350
+ if (t) report(11, 'WARN', r.file, t.line, `image transforms on hover (${r.selector})`, 'Imagery sits still; give the container the feedback');
1351
+ }
1352
+ }
1353
+
1354
+ function check12(ctx) {
1355
+ const uiSel = /:hover|:active|:focus|\b(btn|button|modal|dialog|tooltip|dropdown|menu|popover|toast|tab|input|select|switch|toggle|accordion)\b/i;
1356
+ const playful = ctx.genre === 'playful' || /genre:\s*playful/.test(ctx.stampText || '');
1357
+ const declared = playful && /--ease-spring/.test(Object.keys(ctx.tokens).join(' '));
1358
+ const springHome = (r) => /primary|cta|press|character|mascot|hero__btn|btn--solid|btn--main/i.test(r.selector);
1359
+ const springSels = new Set();
1360
+ for (const r of ctx.rules) {
1361
+ if (!uiSel.test(r.selector)) continue;
1362
+ for (const d of r.decls) {
1363
+ if (!/transition|animation/.test(d.prop)) continue;
1364
+ if (declared && /var\(--ease-spring\)/.test(d.value) && springHome(r) && (springSels.add(baseSelector(r.selector).split(':')[0]), springSels.size <= 2)) continue;
1365
+ const re = /cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/g;
1366
+ let m;
1367
+ while ((m = re.exec(resolveVars(d.value, ctx.tokens)))) {
1368
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
1369
+ if (y1 > 1.05 || y2 > 1.05 || y1 < -0.05 || y2 < -0.05) {
1370
+ report(12, 'FAIL', r.file, d.line, `overshoot easing ${m[0]} on ${r.selector}`, 'Use exponential ease-out on UI state changes');
1371
+ }
1372
+ }
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ function check10Motion(ctx) {
1378
+ const hardRe = /(?<![\w-])(width|height|top|left|right|bottom|inset(?:-[a-z-]+)?|flex-basis|grid-template-columns)(?![\w-])/;
1379
+ const softRe = /(?<![\w-])(margin(?:-[a-z]+)?|padding(?:-[a-z]+)?)(?![\w-])/;
1380
+ const a11ySel = /skip|sr-only|visually-hidden/i;
1381
+ for (const r of ctx.rules) {
1382
+ if (a11ySel.test(r.selector)) continue;
1383
+ if (r.keyframes) {
1384
+ for (const d of r.decls) {
1385
+ if (/^(min|max)-/.test(d.prop)) continue;
1386
+ if (hardRe.test(d.prop)) {
1387
+ report('10a', 'FAIL', r.file, d.line, `@keyframes ${r.keyframes} animates ${d.prop}`, 'Animate transform or opacity instead');
1388
+ } else if (softRe.test(d.prop)) {
1389
+ report('10b', 'WARN', r.file, d.line, `@keyframes ${r.keyframes} animates ${d.prop}`, 'Animate transform or opacity instead');
1390
+ }
1391
+ }
1392
+ continue;
1393
+ }
1394
+ for (const d of r.decls) {
1395
+ if (d.prop === 'transition' || d.prop === 'transition-property') {
1396
+ const hard = hardRe.exec(d.value);
1397
+ const soft = softRe.exec(d.value);
1398
+ if (hard) {
1399
+ report('10a', 'FAIL', r.file, d.line, `transitioning ${hard[1]} on ${r.selector}`, 'Animate transform or opacity instead');
1400
+ } else if (soft) {
1401
+ report('10b', 'WARN', r.file, d.line, `transitioning ${soft[1]} on ${r.selector}`, 'Animate transform or opacity instead');
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ function check15(ctx) {
1409
+ for (const r of ctx.rules) {
1410
+ if (!/:focus(-visible|-within)?\b/.test(r.selector)) continue;
1411
+ const withinOnly = !/:focus(-visible)?(?![\w-])/.test(r.selector);
1412
+ for (const d of r.decls) {
1413
+ if (d.prop === 'transition' && (withinOnly ? /outline|box-shadow|\ball\b/ : /outline|box-shadow|opacity|\ball\b/).test(d.value)) {
1414
+ report(15, 'FAIL', r.file, d.line, `focus ring transition on ${r.selector}`, 'Remove the transition; rings appear instantly');
1415
+ }
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ function check17(ctx) {
1421
+ const tips = ctx.rules.filter((r) => /tooltip|tip\b/i.test(r.selector));
1422
+ const delayed = (r) => r.decls.find((d) => /^transition(-delay)?$/.test(d.prop) && /(\d{3,})ms|([1-9][\d.]*)s\b/.test(d.value));
1423
+ const hover = tips.filter((r) => /:hover/.test(r.selector)).map(delayed).find(Boolean);
1424
+ const focus = tips.filter((r) => /:focus/.test(r.selector)).map(delayed).find(Boolean);
1425
+ if (hover && focus) {
1426
+ report(17, 'WARN', tips[0].file, focus.line, 'tooltip focus delay matches hover delay', 'Hover delay 800-1000ms; focus delay 0');
1427
+ }
1428
+ }
1429
+
1430
+ function check18(ctx) {
1431
+ const autoish = /carousel|marquee|ticker|rotate|slider|scroll|auto/i;
1432
+ const paused = ctx.rules.some((r) => /:hover|:focus|\[data-loop\]/.test(r.selector) && r.decls.some((d) => d.prop === 'animation-play-state' && /paused/.test(d.value)));
1433
+ for (const r of ctx.rules) {
1434
+ if (r.keyframes || !autoish.test(r.selector)) continue;
1435
+ for (const d of r.decls) {
1436
+ if (/^animation(-iteration-count)?$/.test(d.prop) && /\binfinite\b/.test(d.value) && !paused) {
1437
+ report(18, 'WARN', r.file, d.line, `infinite animation on ${r.selector} with no pause state`, 'Pause on hover and focus');
1438
+ }
1439
+ }
1440
+ }
1441
+ }
1442
+
1443
+ function check19(ctx) {
1444
+ for (const doc of ctx.docs) {
1445
+ const t = maskedProse(doc);
1446
+ const patterns = [
1447
+ [/\b(jane doe|john smith|john doe|example user)\b/i, 'placeholder name'],
1448
+ [/\bAcme\b/, 'startup cliche name'],
1449
+ [/\bNexus\b/, 'startup cliche name'],
1450
+ ];
1451
+ const title = (/<title\b[^>]*>([^<]*)<\/title>/i.exec(doc.src) || [])[1] || '';
1452
+ for (const [re, label] of patterns) {
1453
+ const m = re.exec(t);
1454
+ if (!m) continue;
1455
+ if (label === 'startup cliche name' && (re.test(title) || (t.match(new RegExp(re.source, 'g')) || []).length >= 3)) continue;
1456
+ report(19, 'FAIL', doc.file, doc.lineOf(m.index), `${label}: "${m[0]}"`, 'Use plausible audience-fitting names');
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ function check20(ctx) {
1462
+ if (ctx.tokensOnly) return;
1463
+ const stamped = (src) => {
1464
+ const lines = src.split('\n').map((l) => l.trim()).filter(Boolean);
1465
+ let i = 0;
1466
+ while (i < lines.length && /^@charset\b/i.test(lines[i])) i++;
1467
+ return i < lines.length && /^\/\*\s*Hallmark/.test(lines[i]);
1468
+ };
1469
+ let ok = false;
1470
+ const cssSources = [];
1471
+ for (const f of ctx.cssFiles) cssSources.push(f.raw);
1472
+ for (const doc of ctx.docs) for (const s of doc.styles) cssSources.push(s.css);
1473
+ for (const s of cssSources) if (stamped(s)) { ok = true; break; }
1474
+ const pageCss = ctx.cssFiles.length || ctx.docs.some((d) => d.kind === 'html' && d.styles.length);
1475
+ if (!ok && cssSources.length && pageCss) {
1476
+ const where = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;
1477
+ report(20, 'FAIL', where, 1, 'no Hallmark stamp on the first non-empty line of any CSS', 'The stamp comment is line 1 of the CSS; only @charset may precede it');
1478
+ }
1479
+ }
1480
+
1481
+ function check22(ctx) {
1482
+ if (ctx.genre === 'modern-minimal') return;
1483
+ const MASK_PROPS = /(^|-)mask(-image|-composite|-mode)?$|^filter$|^backdrop-filter$/;
1484
+ for (const r of ctx.rules) {
1485
+ for (const d of r.decls) {
1486
+ if (MASK_PROPS.test(d.prop)) continue;
1487
+ const re = /oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+[\d.]+(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)/g;
1488
+ let m;
1489
+ while ((m = re.exec(d.value))) {
1490
+ const chroma = parseFloat(m[2]);
1491
+ const alpha = m[3] ? (m[3].endsWith('%') ? parseFloat(m[3]) / 100 : parseFloat(m[3])) : 1;
1492
+ if (chroma === 0 && alpha >= 1) {
1493
+ report(22, 'FAIL', r.file, d.line, `zero-chroma ${m[0]} (${d.prop} on ${r.selector})`, 'Give neutrals at least 0.005 chroma');
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ function check23(ctx) {
1501
+ if (ctx.posture === 'drenched') return;
1502
+ const ACC = /var\(\s*--color-accent(?:-2)?\s*[,)]/;
1503
+ const SURFACE = /(^|[\s,>~+])(body|html|main|section|header|footer|aside)(\b|$)|\bhero\b|__hero|banner|\bstrip\b|\bband\b/i;
1504
+ const big = (r) => r.decls.some((d) =>
1505
+ (/^(min-)?height$/.test(d.prop) && (parseFloat((/([\d.]+)\s*(vh|dvh|svh)/.exec(d.value) || [])[1] || 0) >= 40 || (pxOf(d.value) ?? 0) >= 320)) ||
1506
+ (d.prop === 'inset' && /^0(px)?(\s|$)/.test(d.value.trim())));
1507
+ for (const r of ctx.rules) {
1508
+ if (isTokenRule(r) || r.keyframes) continue;
1509
+ for (const d of r.decls) {
1510
+ if (!/^background(-color|-image)?$/.test(d.prop) || !ACC.test(d.value)) continue;
1511
+ if (/\/\s*0?\.[0-2]\d*/.test(d.value)) continue;
1512
+ if (ctx.genre === 'atmospheric' && /radial-gradient/i.test(d.value)) continue;
1513
+ if (SURFACE.test(r.selector) || big(r)) {
1514
+ report(23, 'WARN', r.file, d.line,
1515
+ `accent background on viewport-scale rule "${trunc(r.selector, 44)}"`,
1516
+ 'Accent stays <= 5% of a viewport; large fills belong to paper/field tokens or a declared posture in the stamp');
1517
+ }
1518
+ }
1519
+ const col = r.decls.find((d) => d.prop === 'color' && ACC.test(d.value));
1520
+ const fsDecl = r.decls.find((d) => d.prop === 'font-size');
1521
+ if (col && fsDecl) {
1522
+ const rv = resolveVars(fsDecl.value, ctx.tokens);
1523
+ const clampMax = /,\s*([^,()]+)\)\s*$/.exec(rv);
1524
+ const maxPx = pxOf(rv) ?? (clampMax ? pxOf(clampMax[1].trim()) : null);
1525
+ if ((maxPx != null && maxPx >= 48) || /--text-display\b/.test(fsDecl.value)) {
1526
+ report(23, 'WARN', r.file, col.line,
1527
+ `display-size text set in accent on "${trunc(r.selector, 44)}"`,
1528
+ 'Accent marks emphasis; set display type in ink and keep accent for small signals');
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ function check24(ctx) {
1535
+ const props = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;
1536
+ for (const r of ctx.rules) {
1537
+ for (const d of r.decls) {
1538
+ if (!props.test(d.prop)) continue;
1539
+ const re = /(-?\d+(?:\.\d+)?)px/g;
1540
+ let m;
1541
+ while ((m = re.exec(d.value))) {
1542
+ const v = Math.abs(parseFloat(m[1]));
1543
+ if (v > 2 && (v % 4 !== 0 || !Number.isInteger(v))) {
1544
+ report('24b', 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} (off the 4px scale) on ${r.selector}`, 'Use the 4px spacing scale');
1545
+ }
1546
+ }
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
+ function check24a(ctx) {
1552
+ const spaceVals = new Map();
1553
+ for (const [k, v] of Object.entries(ctx.tokens)) {
1554
+ if (!/^--space-/.test(k)) continue;
1555
+ const px = pxOf(resolveVars(v, ctx.tokens));
1556
+ if (px != null && px > 0) spaceVals.set(px, k);
1557
+ }
1558
+ if (!spaceVals.size) return;
1559
+ const props = /^(padding|margin|gap|row-gap|column-gap)(-[a-z]+)?$/;
1560
+ const counts = new Map();
1561
+ for (const r of ctx.rules) {
1562
+ if (r.keyframes || isTokenRule(r)) continue;
1563
+ for (const d of r.decls) {
1564
+ if (!props.test(d.prop)) continue;
1565
+ const raw = d.value.replace(/var\([^)]*\)/g, ' ');
1566
+ for (const m of raw.matchAll(/(?<![\w.-])(-?\d+(?:\.\d+)?)(px|rem)\b/g)) {
1567
+ const px = Math.abs(pxOf(m[1] + m[2]) ?? 0);
1568
+ if (!px || !spaceVals.has(px)) continue;
1569
+ const n = (counts.get(r.file) || 0) + 1;
1570
+ counts.set(r.file, n);
1571
+ if (n <= 8) report('24a', 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${trunc(r.selector, 32)} where ${spaceVals.get(px)} exists`, `Use var(${spaceVals.get(px)})`);
1572
+ break;
1573
+ }
1574
+ }
1575
+ }
1576
+ for (const [file, n] of counts) if (n > 8) report('24a', 'FAIL', file, 1, `+${n - 8} more raw spacing lengths with a matching --space-* token in this file`, 'Use the tokens');
1577
+ }
1578
+
1579
+ function shortProse(ctx, sel) {
1580
+ const cls = [...sel.matchAll(/\.([\w-]+)/g)].map((m) => m[1]).pop();
1581
+ if (!cls) return false;
1582
+ const texts = [];
1583
+ for (const doc of ctx.docs) for (const m of doc.src.matchAll(new RegExp(`<(\\w+)[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>([\\s\\S]*?)</\\1>`, 'g'))) {
1584
+ for (const p of (/^p$/i.test(m[1]) ? [m[2]] : m[2].match(/<p\b[^>]*>[\s\S]*?<\/p>/g) || [])) texts.push(p.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
1585
+ }
1586
+ return texts.length > 0 && texts.every((t) => t.length <= 200 && (t.match(/[.!?](?:\s|$)/g) || []).length <= 2);
1587
+ }
1588
+
1589
+ function check25(ctx) {
1590
+ const proseish = /(^|[\s,>+~(])p\b|lede|prose|copy|__body|__text|__desc|paragraph/i;
1591
+ for (const r of ctx.rules) {
1592
+ if (!proseish.test(r.selector) || HEADING_SEL.test(r.selector)) continue;
1593
+ for (const d of r.decls) {
1594
+ if (d.prop !== 'max-width') continue;
1595
+ const m = /^([\d.]+)ch$/.exec(resolveVars(d.value, ctx.tokens).trim());
1596
+ if (m) {
1597
+ const ch = parseFloat(m[1]);
1598
+ const floor = /lede|hero|caption|figcaption|__line|__sub|subhead|tagline/i.test(r.selector) || shortProse(ctx, r.selector) ? 28 : 45;
1599
+ if (ch < floor || ch > 75) {
1600
+ report(25, 'WARN', r.file, d.line, `measure ${ch}ch on ${r.selector} (${floor}-75ch range)`, 'Set prose measure between 45 and 75ch; a one-sentence lede or caption from 28ch');
1601
+ }
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ function check26(ctx) {
1608
+ const hasInteractive = ctx.docs.some((doc) => /<(a|button|input|select|textarea)\b|role\s*=\s*["']button/i.test(doc.src))
1609
+ || ctx.rules.some((r) => selectorIsInteractive(r.selector));
1610
+ if (!hasInteractive) return;
1611
+ const has = (pseudo) => ctx.rules.some((r) => r.selector.includes(pseudo));
1612
+ const tv = twGroup(ctx).variants;
1613
+ const shared = ctx.shared || {};
1614
+ const once = (key) => !shared.once || (!shared.once.has(key) && !!shared.once.add(key));
1615
+ const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file || '?';
1616
+ if (!has(':focus-visible') && !has(':focus') && !shared.focus && ![...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v)) && once('26a')) {
1617
+ report('26a', 'FAIL', file, 1, 'no :focus-visible styling anywhere', 'A focus ring at 3:1 or better, instant');
1618
+ }
1619
+ if (!has(':active') && !shared.active && !tv.has('active') && once('26b:active')) {
1620
+ report('26b', 'FAIL', file, 1, 'no :active styling anywhere', 'All eight states; guard: not a form control, no aria-disabled anywhere');
1621
+ }
1622
+ const hasFormish = ctx.docs.some((doc) => /<(button|input|select|textarea)\b/i.test(doc.src));
1623
+ if (hasFormish && !has(':disabled') && !shared.disabled && !tv.has('disabled') && !tv.has('aria-disabled') && !ctx.rules.some((r) => /\[disabled\]|aria-disabled/.test(r.selector)) && once('26b:disabled')) {
1624
+ report('26b', 'FAIL', file, 1, 'no :disabled styling anywhere', 'All eight states; guard: not a form control, no aria-disabled anywhere');
1625
+ }
1626
+ }
1627
+
1628
+ function check27(ctx) {
1629
+ const tw = twGroup(ctx);
1630
+ const moves = (r) => r.decls.some((d) => /^(transform|translate|scale|rotate)$/.test(d.prop) && !/^none\b/.test(d.value.trim()));
1631
+ const hasMotion = ctx.rules.some((r) => r.keyframes)
1632
+ || ctx.rules.some((r) => r.decls.some((d) => /^transition/.test(d.prop) && /transform/.test(d.value)))
1633
+ || (ctx.rules.some((r) => r.decls.some((d) => /^transition/.test(d.prop) && /(^|[\s,])all\b/.test(d.value))) && ctx.rules.some((r) => /:(hover|active|focus)/.test(r.selector) && moves(r)));
1634
+ if (!hasMotion && !tw.motion) return;
1635
+ const hasPRM = ctx.rules.some((r) => r.media && /prefers-reduced-motion/i.test(r.media))
1636
+ || ctx.cssFiles.some((f) => /prefers-reduced-motion/i.test(stripComments(f.raw)))
1637
+ || ctx.docs.some((doc) => doc.styles.some((s) => /prefers-reduced-motion/i.test(stripComments(s.css))))
1638
+ || tw.variants.has('motion-reduce') || tw.variants.has('motion-safe') || !!ctx.shared?.prm;
1639
+ if (!hasPRM && ctx.shared) {
1640
+ if (ctx.shared.once.has('27')) return;
1641
+ ctx.shared.once.add('27');
1642
+ }
1643
+ if (!hasPRM && !hasMotion) {
1644
+ const m = tw.motion;
1645
+ report(27, 'FAIL', m.doc.file, m.line, `motion utilities (${m.info.animate || 'a transition on a transform'}) with no motion-reduce: or motion-safe: variant anywhere`, 'Add motion-reduce: fallbacks, or put the motion behind motion-safe:');
1646
+ } else if (!hasPRM) {
1647
+ const kf = ctx.rules.find((r) => r.keyframes);
1648
+ const file = kf ? kf.file : (ctx.cssFiles[0]?.file || ctx.docs[0]?.file || '?');
1649
+ report(27, 'FAIL', file, kf ? kf.line : 1, 'motion present, no prefers-reduced-motion block', 'Add a prefers-reduced-motion fallback');
1650
+ }
1651
+ }
1652
+
1653
+ function check28(ctx) {
1654
+ for (const doc of ctx.docs) {
1655
+ let firstMedia = true, seenH1 = false, heroPassed = false;
1656
+ for (const t of doc.tags) {
1657
+ if (t.close) { if (seenH1 && (t.name === 'section' || t.name === 'header')) heroPassed = true; continue; }
1658
+ if (t.name === 'h1') seenH1 = true;
1659
+ if (t.name === 'video') {
1660
+ if (hasAttr(t.attrs, 'autoplay') && !hasAttr(t.attrs, 'muted')) {
1661
+ report('28a', 'FAIL', doc.file, t.line, 'autoplay video without muted', 'autoplay muted loop playsinline, with poster');
1662
+ }
1663
+ if (!getAttr(t.attrs, 'poster')) {
1664
+ report('28b', 'FAIL', doc.file, t.line, 'video without poster', 'Add a poster frame; guard: below the fold and not the LCP');
1665
+ }
1666
+ if ((getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy' && firstMedia) {
1667
+ report('28a', 'WARN', doc.file, t.line, 'lazy-loaded hero video', 'Never lazy on the LCP video; preload its poster with fetchpriority=high');
1668
+ }
1669
+ firstMedia = false;
1670
+ }
1671
+ if (t.name === 'img') {
1672
+ if (firstMedia && !heroPassed && (getAttr(t.attrs, 'loading') || '').toLowerCase() === 'lazy') {
1673
+ report('28a', 'WARN', doc.file, t.line, 'lazy-loaded first image (likely LCP)', 'fetchpriority=high on the LCP element');
1674
+ }
1675
+ firstMedia = false;
1676
+ }
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ function check30(ctx) {
1682
+ const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
1683
+ const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)/u;
1684
+ for (const doc of ctx.docs) {
1685
+ const lines = new Set();
1686
+ let n = 0;
1687
+ const flag = (line, what) => { if (lines.has(line) || n >= 8) return; lines.add(line); n++; report('30a', 'FAIL', doc.file, line, what, 'One icon library, a custom SVG, or the word alone'); };
1688
+ for (let i = 0; i < doc.tags.length; i++) {
1689
+ const t = doc.tags[i];
1690
+ if (t.close || t.selfClosing) continue;
1691
+ const cls = getAttr(t.attrs, 'class') || '';
1692
+ const iconish = /icon|card|step|tier|feature|value|prop|badge/i.test(cls);
1693
+ const inner = innerOf(doc, i);
1694
+ if (!inner) continue;
1695
+ const content = inner.inner.replace(/<[^>]*>/g, '').trim();
1696
+ if (content && content.length <= 4 && EMOJI.test(content) && (iconish || !/<[a-z]/.test(inner.inner))) {
1697
+ lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI)));
1698
+ report('30a', 'FAIL', doc.file, t.line, `emoji icon "${content}" in <${t.name} class="${trunc(cls, 28)}">`, 'One icon library or custom SVG');
1699
+ continue;
1700
+ }
1701
+ if (!/^(h[1-6]|button|a|label|th|summary|option|legend|dt)$/.test(t.name) || /<(p|div|ul|ol|table|section)\b/i.test(inner.inner)) continue;
1702
+ const text = plainText(inner.inner);
1703
+ const at = inner.inner.search(GLYPH);
1704
+ if (!text || at < 0) continue;
1705
+ const line = doc.lineOf(t.end + at);
1706
+ flag(line, `emoji in <${t.name}> "${trunc(text, 32)}"`);
1707
+ }
1708
+ const raw = doc.raw || doc.src;
1709
+ const body = doc.kind === 'html' ? doc.masked : raw;
1710
+ const lineAt = (text, pos) => text.slice(0, pos).split('\n').length;
1711
+ for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)[^<>{}\n]{0,40})/gu)) flag(lineAt(body, m.index + m[0].length - m[1].length), `text opens with an emoji: "${trunc(m[1].trim(), 32)}"`);
1712
+ for (const m of raw.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(lineAt(raw, m.index), `emoji in a placeholder "${trunc(m[1], 28)}"`);
1713
+ if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️?)‍?){1,3})\s*\1/gu)) flag(lineAt(raw, m.index), `emoji passed as an icon: ${m[2]}`);
1714
+ }
1715
+ const libs = new Map([
1716
+ ['material', /material-icons|material-symbols/i],
1717
+ ['heroicons', /heroicon/i],
1718
+ ['lucide', /lucide/i],
1719
+ ['phosphor', /phosphor|\bph-(?:bold|fill|light|duotone|[a-z]+-[a-z]+)/i],
1720
+ ['font-awesome', /font-?awesome|\bfa-(solid|regular|brands|light)\b/i],
1721
+ ['feather', /feather-icons|\bfeather\b/i],
1722
+ ['tabler', /tabler-icons/i],
1723
+ ['bootstrap-icons', /bootstrap-icons|\bbi-[a-z]/i],
1724
+ ]);
1725
+ const found = new Set();
1726
+ const hay = ctx.docs.map((d) => d.src).join('\n') + ctx.cssFiles.map((f) => f.raw).join('\n');
1727
+ for (const [name, re] of libs) if (re.test(hay)) found.add(name);
1728
+ if (found.size >= 2) {
1729
+ report('30b', 'WARN', ctx.docs[0]?.file || ctx.cssFiles[0]?.file, 1, `icon libraries mixed: ${[...found].join(' + ')}`, 'One icon library per page; guard: the second is a brand mark in at most two slots');
1730
+ }
1731
+ }
1732
+
1733
+ function check33(ctx) {
1734
+ const labeledAttrs = (a) => hasAttr(a, 'aria-hidden') || getAttr(a, 'aria-label') != null
1735
+ || getAttr(a, 'aria-labelledby') != null
1736
+ || ['img', 'presentation', 'none'].includes((getAttr(a, 'role') || '').toLowerCase());
1737
+ for (const doc of ctx.docs) {
1738
+ const stack = [];
1739
+ for (const t of doc.tags) {
1740
+ if (t.close) {
1741
+ for (let i = stack.length - 1; i >= 0; i--) {
1742
+ if (stack[i].name === t.name) { stack.splice(i); break; }
1743
+ }
1744
+ continue;
1745
+ }
1746
+ if (t.name === 'svg' || t.name === 'canvas') {
1747
+ const ancestorLabeled = stack.some((s) => s.labeled);
1748
+ const titled = t.name === 'svg' && !t.selfClosing && /<title\b[^>]*>\s*\S/i.test(innerOf(doc, doc.tags.indexOf(t))?.inner || '');
1749
+ if (!labeledAttrs(t.attrs) && !ancestorLabeled && !titled) {
1750
+ report(33, 'FAIL', doc.file, t.line, `<${t.name}> without aria-hidden or aria-label`, 'Add aria-hidden or an aria-label');
1751
+ }
1752
+ }
1753
+ if (!t.selfClosing) stack.push({ name: t.name, labeled: labeledAttrs(t.attrs) });
1754
+ }
1755
+ }
1756
+ }
1757
+
1758
+ function check34(ctx) {
1759
+ const clipped = new Set();
1760
+ let hidden = null;
1761
+ for (const r of ctx.rules) {
1762
+ if (!/(^|[\s,])(html|body)\b/.test(r.selector)) continue;
1763
+ const targets = r.selector.split(',').map((p) => (/^\s*(html|body)\s*$/.exec(p) || [])[1]).filter(Boolean);
1764
+ for (const d of r.decls) {
1765
+ if (d.prop === 'overflow-x' || d.prop === 'overflow') {
1766
+ if (/\bclip\b/.test(d.value)) for (const t of targets) clipped.add(t);
1767
+ else if (/\bhidden\b/.test(d.value)) hidden = { file: r.file, line: d.line, sel: r.selector };
1768
+ }
1769
+ }
1770
+ }
1771
+ const hiddenUtil = [];
1772
+ for (const it of twGroup(ctx).items) {
1773
+ if (it.idx < 0 || !/^(html|body)$/.test(it.name)) continue;
1774
+ if (it.info.oxClip) clipped.add(it.name);
1775
+ if (it.info.oxHidden) hiddenUtil.push(it);
1776
+ }
1777
+ for (const it of hiddenUtil) report(34, 'FAIL', it.doc.file, it.line, `${it.info.oxHidden} on <${it.name}> (breaks sticky)`, 'overflow-x-clip on both html and body, never hidden');
1778
+ const shell = (d) => d.kind === 'html' && !d.styles.length && /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(d.raw || d.src) && ![...(d.raw || d.src).matchAll(/<link\b[^>]*>/gi)].some((m) => /rel\s*=\s*["']stylesheet/i.test(m[0]) && !/href\s*=\s*["']https?:/i.test(m[0]));
1779
+ const pageish = ctx.docs.some((d) => (d.kind === 'html' && !shell(d)) || (d.kind !== 'html' && d.tags.some((t) => !t.close && (t.name === 'html' || t.name === 'body'))));
1780
+ const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;
1781
+ const both = clipped.has('html') && clipped.has('body');
1782
+ if (!both && hidden) {
1783
+ report(34, 'FAIL', hidden.file, hidden.line, `overflow-x: hidden on ${hidden.sel} (breaks sticky)`, 'Use overflow-x: clip on both html and body, never hidden');
1784
+ } else if (!both && file && ctx.docs.length && pageish && !hiddenUtil.length) {
1785
+ const where = clipped.size ? `overflow-x: clip on ${[...clipped][0]} only` : 'no overflow-x: clip on html and body';
1786
+ report(34, 'FAIL', file, 1, where, 'overflow-x: clip on both html and body');
1787
+ }
1788
+ for (const r of ctx.rules) {
1789
+ for (const d of r.decls) {
1790
+ if (/^(width|min-width)$/.test(d.prop) && /(^|[^\w.-])100vw\b/.test(d.value)) {
1791
+ report(34, 'FAIL', r.file, d.line, `${d.prop}: ${trunc(d.value, 28)} on ${r.selector} (scrollbar overflow)`, 'width: 100% with container padding');
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+
1797
+ function check37(ctx) {
1798
+ const families = new Map();
1799
+ const generic = GENERIC_FAMILIES;
1800
+ for (const r of ctx.rules) {
1801
+ if (r.selector === '@font-face') continue;
1802
+ for (const d of r.decls) {
1803
+ if (d.prop !== 'font-family' && !/^--font-/.test(d.prop)) continue;
1804
+ if (/^--font-/.test(d.prop) && !isTokenRule(r)) continue;
1805
+ const fam = firstFamily(resolveVars(d.value, ctx.tokens));
1806
+ if (!fam || generic.has(fam) || fam.startsWith('var(')) continue;
1807
+ if (![...families.keys()].some((k) => famKey(k) === famKey(fam))) families.set(fam, { file: r.file, line: d.line });
1808
+ }
1809
+ }
1810
+ if (families.size > 3) {
1811
+ const list = [...families.keys()].join(', ');
1812
+ const first = [...families.values()][3];
1813
+ report(37, 'FAIL', first.file, first.line, `${families.size} font families: ${trunc(list, 56)}`, 'Drop the extra family; three is the ceiling');
1814
+ }
1815
+ }
1816
+
1817
+ function check38a(ctx) {
1818
+ for (const r of ctx.rules) {
1819
+ if (!HEADING_SEL.test(r.selector)) continue;
1820
+ for (const d of r.decls) {
1821
+ if (d.prop === 'font-style' && /italic|oblique/.test(d.value)) {
1822
+ report('38ii', 'FAIL', r.file, d.line, `italic display type on ${r.selector}`, 'Roman headers; emphasis via weight or accent');
1823
+ }
1824
+ }
1825
+ }
1826
+
1827
+ const normSel = ctx.rules.filter((r) => /(^|[\s,>+~])(em|i)\b/.test(r.selector)
1828
+ && r.decls.some((d) => d.prop === 'font-style' && /normal/.test(d.value))).flatMap((r) => r.selector.split(',').map((x) => x.trim()));
1829
+ const normalised = (level, classes) => normSel.some((sel) => /^(em|i)$/.test(sel) || new RegExp(`(^|[\\s,>+~])h${level}\\b`).test(sel) || classes.some((c) => sel.includes('.' + c)));
1830
+ for (const doc of ctx.docs) {
1831
+ const re = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
1832
+ let m;
1833
+ while ((m = re.exec(doc.src))) {
1834
+ const classes = ((/class\s*=\s*["']([^"']*)["']/i.exec(m[2]) || [])[1] || '').split(/\s+/).filter(Boolean);
1835
+ if (/<(em|i)\b/i.test(m[3]) && !normalised(m[1], classes)) {
1836
+ report('38i', 'FAIL', doc.file, doc.lineOf(m.index), `<em>/<i> inside <h${m[1]}>`, 'Roman headers; emphasis via weight or accent');
1837
+ }
1838
+ }
1839
+ }
1840
+ }
1841
+
1842
+ function check39(ctx) {
1843
+ const hasInputs = ctx.docs.some((doc) => /<(input|textarea|select)\b/i.test(doc.src))
1844
+ || ctx.rules.some((r) => /(^|[\s,>+~])(input|textarea|select)\b/.test(r.selector));
1845
+ if (!hasInputs) return;
1846
+ const inputSel = /(^|[\s,>+~])(input|textarea|select)\b|__input|__field|\.(input|field)\b/;
1847
+ const widthOf = (v) => { const m = /(\d+(?:\.\d+)?)px/.exec(v); return m ? parseFloat(m[1]) : null; };
1848
+ let baseW = null, baseWhere = null;
1849
+ for (const r of ctx.rules) {
1850
+ if (!inputSel.test(r.selector) || /:hover|:focus|:active|:disabled|\[|:checked/.test(r.selector)) {
1851
+ continue;
1852
+ }
1853
+ for (const d of r.decls) {
1854
+ if (d.prop === 'border' || d.prop === 'border-width') {
1855
+ const w = widthOf(d.value);
1856
+ if (w != null && baseW == null) { baseW = w; baseWhere = { file: r.file, line: d.line }; }
1857
+ }
1858
+ }
1859
+ }
1860
+ for (const r of ctx.rules) {
1861
+ if (!inputSel.test(r.selector)) continue;
1862
+ const stateful = /:hover|:focus|\.is-error|\[aria-invalid/.test(r.selector);
1863
+ if (!stateful) continue;
1864
+ for (const d of r.decls) {
1865
+ if (d.prop === 'border' || d.prop === 'border-width') {
1866
+ const w = widthOf(d.value);
1867
+ if (w != null && baseW != null && w !== baseW) {
1868
+ report('39a', 'FAIL', r.file, d.line, `border-width ${baseW}px -> ${w}px on ${r.selector} (layout shift)`, 'Keep border-width constant; move state to outline');
1869
+ }
1870
+ }
1871
+ }
1872
+ if (/:focus/.test(r.selector)) {
1873
+ const setsBorder = r.decls.some((d) => /^border(-color)?$/.test(d.prop));
1874
+ const setsOutline = r.decls.some((d) => /^outline/.test(d.prop));
1875
+ if (setsBorder && !setsOutline) {
1876
+ report('39a', 'FAIL', r.file, r.line, `focus state via border on ${r.selector}`, 'Focus ring via outline, not border');
1877
+ }
1878
+ }
1879
+ }
1880
+ for (const r of ctx.rules) {
1881
+ if (!/:disabled|\[disabled\]/.test(r.selector)) continue;
1882
+ const hasOpacity = r.decls.some((d) => d.prop === 'opacity');
1883
+ const hasCursor = r.decls.some((d) => d.prop === 'cursor' && /not-allowed/.test(d.value));
1884
+ if (hasOpacity && !hasCursor) {
1885
+ report('39a', 'FAIL', r.file, r.line, `disabled via opacity alone on ${r.selector}`, 'Three disabled channels: fill, ink and cursor, plus the disabled attribute');
1886
+ }
1887
+ }
1888
+ }
1889
+
1890
+ function check40_41(ctx) {
1891
+ const bySel = mergedDeclsBySelector(ctx);
1892
+ const resolveColor = (value, local) => {
1893
+ const resolved = resolveVars(value, ctx.tokens, local).trim();
1894
+ return parseColor(resolved);
1895
+ };
1896
+ for (const r of bySel) {
1897
+ const local = {};
1898
+ for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;
1899
+ const colorD = r.decls.find((d) => d.prop === 'color');
1900
+ const bgD = r.decls.find((d) => d.prop === 'background-color')
1901
+ || r.decls.find((d) => d.prop === 'background' && !/gradient|url\(/i.test(d.value));
1902
+ if (colorD && bgD && !/::selection|::placeholder|::-moz/.test(r.selector)) {
1903
+ const fg = resolveColor(colorD.value, local);
1904
+ const bg = resolveColor(bgD.value, local);
1905
+ if (fg && bg && fg.alpha >= 1 && bg.alpha >= 1) {
1906
+ const sizeD = r.decls.find((d) => d.prop === 'font-size');
1907
+ const weightD = r.decls.find((d) => d.prop === 'font-weight');
1908
+ const sizePx = sizeD ? pxOf(resolveVars(sizeD.value, ctx.tokens, local)) : null;
1909
+ const weight = weightD ? parseInt(weightD.value, 10) || 400 : 400;
1910
+ const large = (sizePx != null && sizePx >= 24) || (sizePx != null && sizePx >= 18 && weight >= 700);
1911
+ const threshold = large ? 3.0 : 4.5;
1912
+ const ratio = contrastRatio(fg, bg);
1913
+ if (ratio < threshold - 0.05) {
1914
+ report(40, 'WARN', r.file, colorD.line, `${ratio.toFixed(2)}:1 < ${threshold}:1 on ${r.selector}`, 'Raise the contrast to threshold');
1915
+ }
1916
+ const okFg = srgbToOklch(fg), okBg = srgbToOklch(bg);
1917
+ const btnish = /btn|button|cta|pill|chip/i.test(r.selector);
1918
+ if (btnish && Math.abs(okFg.L - okBg.L) < 0.05 && Math.abs(okFg.C - okBg.C) < 0.05) {
1919
+ report(41, 'WARN', r.file, colorD.line, `button text ~ button fill on ${r.selector} (dL ${Math.abs(okFg.L - okBg.L).toFixed(3)})`, 'Use accent-ink or paper for the label');
1920
+ }
1921
+ }
1922
+ }
1923
+ const faceProp = Object.keys(local).find((k) => /(face|fill|bg)$/.test(k));
1924
+ const inkProp = Object.keys(local).find((k) => /(ink|text)$/.test(k));
1925
+ if (faceProp && inkProp) {
1926
+ const face = resolveColor(local[faceProp], local);
1927
+ const ink = resolveColor(local[inkProp], local);
1928
+ if (face && ink && face.alpha >= 1 && ink.alpha >= 1) {
1929
+ const a = srgbToOklch(face), b = srgbToOklch(ink);
1930
+ if (Math.abs(a.L - b.L) < 0.05 && Math.abs(a.C - b.C) < 0.05) {
1931
+ report(41, 'WARN', r.file, r.line, `${inkProp} ~ ${faceProp} on ${r.selector}`, 'Use accent-ink or paper for the label');
1932
+ }
1933
+ }
1934
+ }
1935
+ }
1936
+ if (ctx.tokens['--color-accent'] && !ctx.tokens['--color-accent-ink']) {
1937
+ const used = ctx.rules.some((r) => !isTokenRule(r) && r.decls.some((d) =>
1938
+ /^background(-color)?$/.test(d.prop) && /var\(\s*--color-accent\s*[),]/.test(d.value)));
1939
+ if (used) {
1940
+ const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;
1941
+ report(41, 'WARN', file, 1, 'accent fills a surface but --color-accent-ink is undefined', 'Define and apply --color-accent-ink');
1942
+ }
1943
+ } else if (ctx.tokens['--color-accent'] && ctx.tokens['--color-accent-ink']) {
1944
+ const a = parseColor(resolveVars(ctx.tokens['--color-accent'], ctx.tokens));
1945
+ const i = parseColor(resolveVars(ctx.tokens['--color-accent-ink'], ctx.tokens));
1946
+ if (a && i && contrastRatio(a, i) < 4.45) {
1947
+ const file = ctx.cssFiles[0]?.file || ctx.docs[0]?.file;
1948
+ report(41, 'WARN', file, 1, `accent-ink vs accent only ${contrastRatio(a, i).toFixed(2)}:1`, 'Accent-ink needs 4.5:1 against accent');
1949
+ }
1950
+ }
1951
+ const sectionish = /section|panel|hero|footer|band|block|col\b|__col|card|cta|dark|invert/i;
1952
+ for (const r of bySel) {
1953
+ if (!sectionish.test(r.selector) || /:hover|:focus|:active|::|@font-face/.test(r.selector)) continue;
1954
+ const local = {};
1955
+ for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;
1956
+ const bgD = r.decls.find((d) => d.prop === 'background-color')
1957
+ || r.decls.find((d) => d.prop === 'background' && !/gradient|url\(/i.test(d.value));
1958
+ if (!bgD) continue;
1959
+ const bg = resolveColor(bgD.value, local);
1960
+ if (!bg || bg.alpha < 1) continue;
1961
+ const okl = srgbToOklch(bg);
1962
+ if (okl.L < 0.5) {
1963
+ const hasColor = r.decls.some((d) => d.prop === 'color');
1964
+ if (!hasColor) {
1965
+ report(41, 'WARN', r.file, bgD.line, `dark surface (L ${(okl.L * 100).toFixed(0)}%) on ${r.selector} sets no text colour`, 'Swap text to paper in the same rule');
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ function check42(ctx) {
1972
+ for (const doc of ctx.docs) {
1973
+ const navIdx = doc.tags.findIndex((t) => !t.close && (t.name === 'nav' || (t.name === 'header' && /banner|nav/i.test(t.attrs))));
1974
+ if (navIdx === -1) continue;
1975
+ const inner = innerOf(doc, navIdx);
1976
+ if (!inner) continue;
1977
+ const links = (inner.inner.match(/<a\b/gi) || []).length;
1978
+ const navTag = doc.tags[navIdx];
1979
+ const navCls = (getAttr(navTag.attrs, 'class') || '').split(/\s+/)[0];
1980
+ const hairline = ctx.rules.some((r) => (navCls && r.selector.includes(navCls) || /(^|[\s,])(nav|header)\b/.test(r.selector))
1981
+ && r.decls.some((d) => /^border(-bottom)?$/.test(d.prop) && /\b1px\b/.test(d.value)));
1982
+ const hasCta = /btn|cta|button/i.test(inner.inner);
1983
+ if (links >= 4 && links <= 6 && hairline && hasCta) {
1984
+ report(42, 'WARN', doc.file, navTag.line, `default nav shape: ${links} links + CTA + 1px hairline`, 'Give the nav a shape of its own (layout.md, navs)');
1985
+ }
1986
+ }
1987
+ }
1988
+
1989
+ function check42Footer(ctx) {
1990
+ for (const doc of ctx.docs) {
1991
+ const idx = doc.tags.findIndex((t) => !t.close && t.name === 'footer');
1992
+ if (idx === -1) continue;
1993
+ const inner = innerOf(doc, idx);
1994
+ if (!inner) continue;
1995
+ const uls = (inner.inner.match(/<ul\b/gi) || []).length;
1996
+
1997
+ const fourEqualCol = ctx.rules.some((r) => /footer/i.test(r.selector) && r.decls.some((d) =>
1998
+ d.prop === 'grid-template-columns'
1999
+ && (/repeat\(\s*4\s*,/.test(d.value)
2000
+ || /^(1fr|minmax\([^)]*\))(\s+(1fr|minmax\([^)]*\))){3,}$/.test(d.value.trim()))));
2001
+
2002
+ let linkGroups = 0;
2003
+ const colRe = /<(ul|div|p|nav)\b[^>]*class\s*=\s*"[^"]*(col|column|links|group)[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi;
2004
+ let cm;
2005
+ while ((cm = colRe.exec(inner.inner))) {
2006
+ if ((cm[3].match(/<a\b/gi) || []).length >= 3) linkGroups++;
2007
+ }
2008
+ for (const um of inner.inner.matchAll(/<ul\b[^>]*>([\s\S]*?)<\/ul>/gi)) {
2009
+ if ((um[1].match(/<a\b/gi) || []).length >= 3) linkGroups++;
2010
+ }
2011
+ if (uls >= 4 || (fourEqualCol && linkGroups >= 3)) {
2012
+ report(42, 'WARN', doc.file, doc.tags[idx].line, `default footer shape: ${uls >= 4 ? uls + ' link columns' : '4 equal columns of links'}`, 'Decide the footer and name it in the stamp, or build another shape');
2013
+ }
2014
+ }
2015
+ }
2016
+
2017
+ function check46(ctx) {
2018
+ const shapes = [
2019
+ /\b\d+(\.\d+)?\s*[x×]\s*faster\b/i,
2020
+ /trusted by [\d,.]+\s*\+?/i,
2021
+ /\b99\.9+\s*%/,
2022
+ /\b[\d,]{4,}\s*\+\s*(teams|users|customers|developers|companies|creators)\b/i,
2023
+ /\b\d+(\.\d+)?\s*%\s*(faster|uptime|conversion|accuracy|retention|growth|fewer|less|more)\b/i,
2024
+ /\+\s*\d+(\.\d+)?\s*%\s*(conversion|growth|revenue|retention)\b/i,
2025
+ /\bsaves?\s+\d+\s+hours?\b/i,
2026
+ ];
2027
+ for (const doc of ctx.docs) {
2028
+ const prose = maskedProse(doc);
2029
+ const lines = new Set();
2030
+ for (const re of shapes) {
2031
+ const m = re.exec(prose);
2032
+ if (!m) continue;
2033
+ const line = doc.lineOf(m.index);
2034
+ if (lines.has(line)) continue;
2035
+ lines.add(line);
2036
+ report('46a', 'WARN', doc.file, line, `metric "${m[0].trim()}": verify the user supplied this number`, 'Confirm the metric or drop the slot');
2037
+ }
2038
+ }
2039
+ }
2040
+
2041
+ const CHROME_47 = [
2042
+ ['47a', /(browser|window|mac|osx)[-_]?(bar|chrome|dots|controls)|traffic[-_]?light|title[-_]?bar|window[-_]?(dots|buttons)|url[-_]?(bar|pill)/i],
2043
+ ['47b', /phone[-_]?(frame|mock(up)?)|device[-_]?frame|\bnotch\b|terminal[-_]?(chrome|frame|bar|dots|header)|(ide|editor)[-_]?(chrome|frame)|(browser|window)[-_]?frame/i],
2044
+ ];
2045
+ function check47(ctx) {
2046
+ for (const [gate, re] of CHROME_47) {
2047
+ for (const doc of ctx.docs) {
2048
+ for (const t of doc.tags) {
2049
+ if (t.close) continue;
2050
+ const cls = getAttr(t.attrs, 'class');
2051
+ const m = cls ? re.exec(cls) : null;
2052
+ if (m) { report(gate, 'FAIL', doc.file, t.line, `re-drawn chrome class "${m[0]}" (class="${trunc(cls, 32)}")`, 'Use a real screenshot in a figure'); break; }
2053
+ }
2054
+ }
2055
+ for (const r of ctx.rules) {
2056
+ const m = re.exec(r.selector);
2057
+ if (m) {
2058
+ report(gate, 'FAIL', r.file, r.line, `re-drawn chrome selector ${r.selector}`, 'Use a real screenshot in a figure');
2059
+ break;
2060
+ }
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ function check48(ctx) {
2066
+ const colorLit = /#[0-9a-fA-F]{3,8}\b|(?<![\w-])rgba?\(|(?<![\w-])hsla?\(|(?<![\w-])oklch\(/;
2067
+ for (const [prop, val] of Object.entries(ctx.tokens)) {
2068
+ if (/^--(font|color)-/.test(prop) && /FILL ME/i.test(val)) {
2069
+ const tr = ctx.rules.find((r) => isTokenRule(r) && r.decls.some((d) => d.prop === prop));
2070
+ report(48, 'FAIL', tr ? tr.file : ctx.cssFiles[0]?.file || ctx.docs[0]?.file, tr ? tr.decls.find((d) => d.prop === prop)?.line : 1, `${prop} is still the scaffold placeholder "FILL ME"`, /^--font-/.test(prop) ? 'Fill the face from the theme tokens (scaffold) or fonts.md on a custom run before building' : 'Fill the colour from the theme tokens (scaffold) or direction.md on a custom run before building');
2071
+ }
2072
+ }
2073
+ for (const r of ctx.rules) {
2074
+ if (isTokenRule(r)) continue;
2075
+ for (const d of r.decls) {
2076
+ if (d.prop === 'font-family') {
2077
+ const v = d.value;
2078
+ if (/FILL ME/i.test(resolveVars(v, ctx.tokens))) {
2079
+ report(48, 'FAIL', r.file, d.line, `font-family on ${r.selector} resolves to the placeholder "FILL ME"`, 'Fill the face from the theme tokens (scaffold) or fonts.md on a custom run before building');
2080
+ continue;
2081
+ }
2082
+ if (!v.includes('var(')) {
2083
+ const fam = firstFamily(v);
2084
+ if (!GENERIC_FAMILIES.has(fam)) {
2085
+ report(48, 'FAIL', r.file, d.line, `font-family literal "${trunc(v, 36)}" on ${r.selector}`, 'Lift the value into the token block');
2086
+ }
2087
+ }
2088
+ continue;
2089
+ }
2090
+ if (!colorLit.test(d.value)) continue;
2091
+ const re = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
2092
+ let m;
2093
+ while ((m = re.exec(d.value))) {
2094
+ const c = parseColor(m[0]);
2095
+ if (!c) continue;
2096
+ if (c.alpha === 0) continue;
2097
+ const isOklch = m[0].toLowerCase().startsWith('oklch');
2098
+ const ev = `${isOklch ? 'oklch' : 'colour'} literal ${trunc(m[0], 30)} outside tokens (${d.prop} on ${trunc(r.selector, 30)})`;
2099
+ report(48, 'FAIL', r.file, d.line, ev, 'Lift the value into the token block');
2100
+ }
2101
+ }
2102
+ }
2103
+ for (const doc of ctx.docs) {
2104
+ const re = /style\s*=\s*"([^"]*)"/gi;
2105
+ let m;
2106
+ while ((m = re.exec(doc.src))) {
2107
+ const lit = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/.exec(m[1]);
2108
+ if (lit && parseColor(lit[0])) {
2109
+ const isOklch = lit[0].toLowerCase().startsWith('oklch');
2110
+ const ev = `inline-style colour literal ${trunc(lit[0], 30)}`;
2111
+ report(48, 'FAIL', doc.file, doc.lineOf(m.index), ev, 'Lift the value into the token block');
2112
+ }
2113
+ }
2114
+ }
2115
+ }
2116
+
2117
+ function check49(ctx) {
2118
+ const nowrapSelectors = ctx.rules.filter((r) => r.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value)));
2119
+ const covered = (cls, tag) => nowrapSelectors.some((r) =>
2120
+ (cls && cls.split(/\s+/).some((c) => c && r.selector.includes('.' + c))) || new RegExp(`(^|[\\s,>+~])${tag}\\b`).test(r.selector));
2121
+ for (const doc of ctx.docs) {
2122
+ for (let i = 0; i < doc.tags.length; i++) {
2123
+ const t = doc.tags[i];
2124
+ if (t.close || t.selfClosing) continue;
2125
+ const cls = getAttr(t.attrs, 'class') || '';
2126
+ const clickable = t.name === 'button' || (t.name === 'a' && /btn|cta|button|nav/i.test(cls))
2127
+ || (/(^|\s)(btn|button)(\s|$|__|--)/i.test(cls) && !/^(section|header|footer|main|article|aside|nav|form|ul|ol)$/.test(t.name));
2128
+ if (!clickable) continue;
2129
+ const inner = innerOf(doc, i);
2130
+ if (!inner) continue;
2131
+ const label = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
2132
+ if (label.length > 24 && !covered(cls, t.name)) {
2133
+ report(49, 'WARN', doc.file, t.line, `long clickable label "${trunc(label, 36)}" without nowrap`, 'Shorten the label or set nowrap');
2134
+ }
2135
+ }
2136
+ }
2137
+ }
2138
+
2139
+ function check50(ctx) {
2140
+ for (const r of ctx.rules) {
2141
+ for (const d of r.decls) {
2142
+ if (d.prop !== 'grid-template-columns') continue;
2143
+ let flat = resolveVars(d.value, ctx.tokens);
2144
+ for (let k = 0; k < 4 && /\((?:[^()]*)\)/.test(flat); k++) flat = flat.replace(/(calc|min|max|clamp|var|fit-content)\(([^()]*)\)/g, 'X');
2145
+ flat = flat.replace(/minmax\([^()]*\)/g, 'MM');
2146
+ const bare = /(?<![\w.-])\d*\.?\d+fr\b/.exec(flat);
2147
+ if (!bare) continue;
2148
+ const classes = (r.selector.match(/\.[a-zA-Z][\w-]*/g) || []).map((c) => c.slice(1));
2149
+ let hit = null;
2150
+ for (const doc of ctx.docs) {
2151
+ for (let i = 0; i < doc.tags.length; i++) {
2152
+ const t = doc.tags[i];
2153
+ if (t.close || t.selfClosing) continue;
2154
+ const cls = getAttr(t.attrs, 'class') || '';
2155
+ if (!classes.some((c) => cls.split(/\s+/).includes(c))) continue;
2156
+ const inner = innerOf(doc, i);
2157
+ if (inner && /<(img|picture|video)\b/i.test(inner.inner)) { hit = { doc, t }; break; }
2158
+ }
2159
+ if (hit) break;
2160
+ }
2161
+ if (hit) {
2162
+ report(50, 'FAIL', r.file, d.line, `bare ${bare[0]} track holds images (${r.selector})`, 'Use minmax(0, 1fr) for image tracks');
2163
+ }
2164
+ }
2165
+ }
2166
+ }
2167
+
2168
+ function check50Wrap(ctx) {
2169
+ const wrapCovered = (cls, tag) => ctx.rules.some((r) => {
2170
+ const has = r.decls.some((d) =>
2171
+ (d.prop === 'overflow-wrap' && /anywhere|break-word/.test(d.value))
2172
+ || (d.prop === 'word-break' && /break-word|break-all/.test(d.value))
2173
+ || (d.prop === 'hyphens' && /auto/.test(d.value)));
2174
+ if (!has) return false;
2175
+ if (/^\*|:where\(|html|body/.test(r.selector)) return true;
2176
+ return (cls && cls.split(/\s+/).some((c) => c && r.selector.includes('.' + c)))
2177
+ || new RegExp(`(^|[\\s,>+~])${tag}\\b`).test(r.selector);
2178
+ });
2179
+ for (const doc of ctx.docs) {
2180
+ for (let i = 0; i < doc.tags.length; i++) {
2181
+ const t = doc.tags[i];
2182
+ if (t.close || t.selfClosing) continue;
2183
+ const cls = getAttr(t.attrs, 'class') || '';
2184
+ const displayish = /^h[12]$/.test(t.name) || /hero__(title|display)|section__title|__display/.test(cls);
2185
+ if (!displayish) continue;
2186
+ const inner = innerOf(doc, i);
2187
+ if (!inner) continue;
2188
+ const textContent = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
2189
+ let risky = null;
2190
+ for (const word of textContent.split(' ')) {
2191
+ for (const seg of word.split('-')) {
2192
+ if (seg.replace(/[^\w]/g, '').length >= 16) { risky = word; break; }
2193
+ }
2194
+ if (risky) break;
2195
+ }
2196
+ if (risky && !wrapCovered(cls, t.name)) {
2197
+ report(50, 'FAIL', doc.file, t.line, `display header word "${trunc(risky, 30)}" with no overflow-wrap`, 'Add overflow-wrap: break-word and min-width: 0');
2198
+ }
2199
+ }
2200
+ }
2201
+ }
2202
+
2203
+ function check53(ctx) {
2204
+ const radios = ctx.rules.filter((r) => /input\[type=["']?radio["']?\]|\.(radio|tab-input|tab-radio)\b/i.test(r.selector));
2205
+ for (const r of radios) {
2206
+ const abs = r.decls.some((d) => d.prop === 'position' && /absolute|fixed/.test(d.value));
2207
+ const topZero = r.decls.some((d) => d.prop === 'top' && /^0(px|rem)?$/.test(d.value.trim()));
2208
+ if (abs && topZero) {
2209
+ const guarded = ctx.docs.some((doc) => /preventScroll/.test(doc.scripts));
2210
+ if (!guarded) {
2211
+ report(53, 'FAIL', r.file, r.line, `radio tabs at position:absolute top:0 (${r.selector}), no JS guard`, 'Keep radios in flow or add a JS guard');
2212
+ }
2213
+ }
2214
+ }
2215
+ }
2216
+
2217
+ const EYEBROW_CONTAINER = /^(div|header|footer|section|article|aside|hgroup|li|main|figure|a|body|blockquote)$/;
2218
+
2219
+ const EYEBROW_SKIP_TAGS = /^(figcaption|label|legend|caption|th|dt|summary|option|code|kbd|button|select|nav|time)$/;
2220
+ const EYEBROW_ANCESTOR_SKIP = /^(form|fieldset|table|thead|tbody|nav|menu|dl|select|figcaption|label|button)$/;
2221
+
2222
+ const EYEBROW_BLOCKY = /<(p|ul|ol|hr|h[1-6]|img|table|figure|section|article|blockquote|form|dl)\b/i;
2223
+ const EYEBROW_CLASS = /\b(eyebrow|kicker|overline|superhead|suphead|rubric|strap(line)?|pre-?(title|head(ing)?)|section-?label|num-?label|chapter|ordinal|topic|category|dept)\b|__(num|label|tag|kicker|chip|badge|meta|index|caps|micro)\b|\b(tag|chip|badge|meta|caps|micro|label|index)\b/i;
2224
+ const EYEBROW_ANNOUNCE = /^(features?|benefits?|introducing|new|how it works?|the (process|tour)|our (process|story|work|team|approach|values?)|why (us|choose|we)|pricing|plans?|faq|questions?|testimonials?|reviews?|trusted by|as seen in|what we do|about( us)?|services?|solutions?|use cases?|get started|contact|team|work|projects?|gallery|blog|news|resources?|examples?|overview)$/i;
2225
+
2226
+ function capsRatio(s) {
2227
+ const letters = s.replace(/[^A-Za-z]/g, '');
2228
+ if (!letters) return 0;
2229
+ return (letters.match(/[A-Z]/g) || []).length / letters.length;
2230
+ }
2231
+
2232
+ function eyebrowHeadingBearing(ctx, B, aPx) {
2233
+ if (/^h[1-6]$/.test(B.name)) return true;
2234
+ if (/<h[1-6][\s>]/i.test(B.html)) return true;
2235
+
2236
+ const cls = getAttr(B.open, 'class') || '';
2237
+ if (!/__(title|display|head(line)?|h\d)\b|hero__/i.test(cls)) return false;
2238
+ const bPx = fontPxOf(ctx, styleFor(ctx, cls, B.name));
2239
+ return bPx != null && aPx != null && bPx >= 1.5 * aPx;
2240
+ }
2241
+
2242
+ function eyebrowHit(ctx, A, B, inOrderedList) {
2243
+ if (EYEBROW_SKIP_TAGS.test(A.name)) return null;
2244
+ if (/(^|\s)for\s*=/i.test(A.open)) return null;
2245
+ if (/role\s*=\s*["']status|aria-live|aria-current|aria-hidden\s*=\s*["']true/i.test(A.open)) return null;
2246
+ const inner = A.html.slice(A.open.length);
2247
+ if (EYEBROW_BLOCKY.test(inner) || /<br\b/i.test(inner)) return null;
2248
+ if (/<time\b[^>]*\sdatetime\s*=/i.test(A.html)) return null;
2249
+ if (/<img\b[^>]*\salt\s*=\s*["'][^"']+/i.test(A.html)) return null;
2250
+
2251
+ const ownHref = A.name === 'a' ? getAttr(A.open, 'href') : null;
2252
+ if (ownHref && !/^#?$/.test(ownHref.trim())) return null;
2253
+ const innerHref = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["']/i.exec(inner);
2254
+ if (innerHref && !/^#?$/.test(innerHref[1].trim())) return null;
2255
+
2256
+ const text = plainText(A.html);
2257
+ if (!text) return null;
2258
+ const words = text.split(/[\s·/|\u2014\u2013]+/).filter(Boolean);
2259
+ if (words.length > 7 || text.length > 56) return null;
2260
+ if (/[\w/-]+\.[a-z]{1,4}$/i.test(text)) return null;
2261
+
2262
+ if (/^[\d.,]+\s*(?:[%°+]|[a-z]{1,3})$/i.test(text)) return null;
2263
+ const stripped = text.replace(/^\s*(?:no\.?\s*)?\d{1,3}(?:\s*[·./\u2014\u2013-]\s*|\s+|$)/i, '').trim();
2264
+ if ((stripped.match(/\d/g) || []).length >= 2) return null;
2265
+
2266
+ if (inOrderedList && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null;
2267
+
2268
+ const cls = getAttr(A.open, 'class') || '';
2269
+ const aDecls = styleFor(ctx, cls, A.name);
2270
+
2271
+ const pos = declValue(ctx, aDecls, 'position');
2272
+ if (pos && /absolute|fixed/.test(pos)) return null;
2273
+ const aPx = fontPxOf(ctx, aDecls);
2274
+ if (!eyebrowHeadingBearing(ctx, B, aPx)) return null;
2275
+
2276
+ if (EYEBROW_CLASS.test(cls)) return { text };
2277
+ let signals = 0;
2278
+ const tt = declValue(ctx, aDecls, 'text-transform');
2279
+ if ((tt && /uppercase/.test(tt)) || (capsRatio(text) >= 0.6 && /[A-Z]/.test(text))) signals++;
2280
+ const tr = trackingEmOf(ctx, aDecls, aPx || 16);
2281
+ if (tr != null && tr >= 0.04) signals++;
2282
+ if (aPx != null && aPx <= 15) signals++;
2283
+ const ff = declValue(ctx, aDecls, 'font-family');
2284
+ if (ff && /mono|--font-label/i.test(ff)) signals++;
2285
+ const fv = declValue(ctx, aDecls, 'font-variant-caps');
2286
+ if (fv && /small-caps/.test(fv)) signals++;
2287
+ if (signals >= 2) return { text };
2288
+ if (EYEBROW_ANNOUNCE.test(stripped)) return { text };
2289
+ return null;
2290
+ }
2291
+
2292
+ function check54(ctx) {
2293
+ for (const doc of ctx.docs) {
2294
+ const anc = ancestorMap(doc);
2295
+ for (let i = 0; i < doc.tags.length; i++) {
2296
+ const t = doc.tags[i];
2297
+ if (t.close || t.selfClosing || !EYEBROW_CONTAINER.test(t.name)) continue;
2298
+ const ancestors = anc.get(i) || [];
2299
+ if (ancestors.some((n) => EYEBROW_ANCESTOR_SKIP.test(n))) continue;
2300
+ const inOl = ancestors.includes('ol');
2301
+ const kids = directChildren(doc, i);
2302
+ for (let k = 0; k < kids.length - 1; k++) {
2303
+ const A = kids[k];
2304
+ let B = null;
2305
+ for (let j = k + 1; j < Math.min(kids.length, k + 3); j++) {
2306
+ const c = kids[j];
2307
+ if (c.name === 'hr' || /aria-hidden\s*=\s*["']true/i.test(c.open)) continue;
2308
+ B = c; break;
2309
+ }
2310
+ if (!B) continue;
2311
+ const hit = eyebrowHit(ctx, A, B, inOl);
2312
+ if (hit) {
2313
+ report(54, 'FAIL', doc.file, doc.tags[A.idx]?.line || t.line,
2314
+ `eyebrow "${trunc(hit.text, 32)}" before <${B.name}>`,
2315
+ 'Cut it; the heading already says this (craft.md § 7 has twelve other openings)');
2316
+ break;
2317
+ }
2318
+ }
2319
+ }
2320
+ }
2321
+ check54Unheaded(ctx);
2322
+ }
2323
+
2324
+ function check54Unheaded(ctx) {
2325
+ for (const doc of ctx.docs) {
2326
+ for (let i = 0; i < doc.tags.length; i++) {
2327
+ const t = doc.tags[i];
2328
+ if (t.close || t.selfClosing || !/^(section|article)$/.test(t.name)) continue;
2329
+ const labelledBy = getAttr(t.attrs, 'aria-labelledby');
2330
+ if (!labelledBy) continue;
2331
+ const body = innerOf(doc, i);
2332
+ if (!body || /<h[1-6][\s>]/i.test(body.inner)) continue;
2333
+ const idRe = new RegExp(`<([a-z][\\w-]*)\\b[^>]*\\sid\\s*=\\s*["']${labelledBy.split(/\s+/)[0]}["'][^>]*>`, 'i');
2334
+ const m = idRe.exec(body.inner);
2335
+ if (!m || /^h[1-6]$/i.test(m[1])) continue;
2336
+ report(54, 'WARN', doc.file, doc.lineOf(body.endPos - body.inner.length + m.index),
2337
+ `section labelled by <${m[1].toLowerCase()}> with no heading element`,
2338
+ 'Promote it to <h2>; a label doing heading duty should be a heading');
2339
+ }
2340
+ }
2341
+ }
2342
+
2343
+ function check55(ctx) {
2344
+ const bySel = mergedDeclsBySelector(ctx);
2345
+ for (const r of bySel) {
2346
+ const upper = r.decls.find((d) => d.prop === 'text-transform' && /uppercase/.test(d.value));
2347
+ if (!upper) continue;
2348
+ const displayish = HEADING_SEL.test(r.selector);
2349
+ const lhD = r.decls.find((d) => d.prop === 'line-height');
2350
+ if (!lhD) continue;
2351
+ const lh = parseFloat(resolveVars(lhD.value, ctx.tokens));
2352
+ if (Number.isNaN(lh) || lh >= 1.02) continue;
2353
+ const bigType = r.decls.some((d) => d.prop === 'font-size' && (pxOf(resolveVars(d.value, ctx.tokens)) ?? 0) >= 28);
2354
+ if (!displayish && !bigType) continue;
2355
+ const nowrap = r.decls.some((d) => d.prop === 'white-space' && /nowrap/.test(d.value));
2356
+ if (!nowrap && lh >= 1.0) continue;
2357
+ report(nowrap ? '55b' : '55a', 'FAIL', r.file, lhD.line, `uppercase + line-height ${lh} on ${r.selector} (cap collision)`, nowrap ? 'A single-line caps lockup holds 1.02 or more' : 'Line-height 1.0 minimum for caps heads that can wrap');
2358
+ }
2359
+ }
2360
+
2361
+ function check56(ctx) {
2362
+ const bySel = mergedDeclsBySelector(ctx);
2363
+ const sticky = [];
2364
+ for (const r of bySel) {
2365
+ const isSticky = r.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value));
2366
+ const topZero = r.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim()));
2367
+ if (isSticky && topZero) sticky.push(r);
2368
+ }
2369
+ if (sticky.length >= 2) {
2370
+ const sels = sticky.map((r) => r.selector).join(' + ');
2371
+ report(56, 'FAIL', sticky[1].file, sticky[1].line, `two sticky top:0 elements: ${trunc(sels, 52)}`, 'Offset the second sticky by banner height');
2372
+ }
2373
+ }
2374
+
2375
+ function checkCopyTell(ctx) {
2376
+ for (const doc of ctx.docs) {
2377
+ const prose = maskedProse(doc);
2378
+ const m = /\w\s--\s\w|[\u2014\u2013]|&mdash;|&ndash;|&#8212;|&#8211;/.exec(prose);
2379
+ if (m) report(19, 'FAIL', doc.file, doc.lineOf(m.index), `dash "${m[0]}" in copy`, 'A comma, a colon, a full stop or parentheses; never a dash');
2380
+ const seenBuzz = new Set();
2381
+ for (const buzz of prose.matchAll(new RegExp(/\b(seamless(ly)?|robust|empower(s|ing)?|supercharge[sd]?|best[- ]in[- ]class|industry[- ]leading|mission[- ]critical|future[- ]proof(ed)?|unleash(es|ed|ing)?|elevate[sd]?|effortless(ly)?|unlock(s|ing)?|next[- ]generation|world[- ]class|cutting[- ]edge|streamline[sd]?|revolutioni[sz]e[sd]?|game[- ]chang(er|ing)|enterprise[- ]grade|quietly (trusted|in use)|built for the modern|in today's)\b/.source, 'gi'))) {
2382
+ const word = buzz[0].toLowerCase();
2383
+ if (seenBuzz.has(word) || seenBuzz.size >= 8) continue;
2384
+ seenBuzz.add(word);
2385
+ report(19, 'FAIL', doc.file, doc.lineOf(buzz.index), `buzzword "${buzz[0]}"`, 'Say what the product does, in its own nouns');
2386
+ }
2387
+ const cadence = prose.match(/(^|[.!?]\s+)(not|no)\s+[a-z][^.!?\n]{0,40}[.]\s+(just|only|a|an)?\s*[a-z][^.!?\n]{0,40}[.]/gi) || [];
2388
+ if (cadence.length >= 3) report(19, 'FAIL', doc.file, doc.lineOf(prose.indexOf(cadence[0].trim()) || 0), `rebuttal cadence repeated ${cadence.length} times ("${trunc(cadence[0].trim(), 40)}")`, 'Once is voice; three times is a tic');
2389
+ }
2390
+ }
2391
+
2392
+ function monoIsMaterial(ctx) {
2393
+ const mono = (v) => v && /mono|code|courier|silkscreen|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|ibm plex mono|press start|vt323|share tech/i.test(firstFamily(resolveVars(v, ctx.tokens)));
2394
+ return mono(ctx.tokens['--font-display']) || mono(ctx.tokens['--font-body']) || mono(ctx.tokens['--font-outlier']);
2395
+ }
2396
+
2397
+ const HIDE_OK = /tooltip|popover|menu|dropdown|modal|dialog|toast|overlay|backdrop|sheet|drawer|panel|:hover|:focus|:checked|:target|\[open|\[aria-(?:expanded|selected|current|pressed)|::before|::after|\.is-|\[hidden|\[aria-hidden|sr-only|visually-hidden|skip|placeholder|::marker|\[data-slot/i;
2398
+ function startingStyleText(css) {
2399
+ let out = '';
2400
+ for (const m of css.matchAll(/@starting-style\s*\{/gi)) {
2401
+ let depth = 1, k = m.index + m[0].length;
2402
+ while (k < css.length && depth > 0) { if (css[k] === '{') depth++; else if (css[k] === '}') depth--; k++; }
2403
+ out += css.slice(m.index, k) + ' ';
2404
+ const open = css.lastIndexOf('{', m.index - 1), close = css.lastIndexOf('}', m.index - 1);
2405
+ if (open > close) out += css.slice(Math.max(close + 1, 0), open) + ' ';
2406
+ }
2407
+ return out;
2408
+ }
2409
+ function check62(ctx) {
2410
+ const starting = startingStyleText(ctx.allCss);
2411
+ const jsScoped = (sel) => /(^|[\s,])(html|body)?\.js\b|\[data-js|\.no-js|html\.has-js/i.test(sel);
2412
+ const animatedNames = new Map();
2413
+ for (const r of ctx.rules) if (r.keyframes) {
2414
+ const arr = animatedNames.get(r.keyframes) || [];
2415
+ arr.push(r); animatedNames.set(r.keyframes, arr);
2416
+ }
2417
+ const animatesVisible = (name) => (animatedNames.get(name) || []).some((r) => /to|100%/.test(r.selector) && r.decls.some((d) => d.prop === 'opacity' && parseFloat(d.value) === 1));
2418
+ const seen = new Set();
2419
+ for (const r of ctx.rules) {
2420
+ if (r.keyframes || (r.media && /\bprint\b/i.test(r.media)) || r.selector === '@font-face') continue;
2421
+ const hidden = r.decls.find((d) => (d.prop === 'opacity' && parseFloat(resolveVars(d.value, ctx.tokens)) === 0) || (d.prop === 'visibility' && /hidden/.test(d.value)));
2422
+ if (!hidden) continue;
2423
+ if (HIDE_OK.test(r.selector.replace(/:not\([^)]*\)/g, '')) || jsScoped(r.selector) || (starting && starting.includes(r.selector))) continue;
2424
+ const revealish = /reveal|fade|animate|aos|in-view|inview|appear|enter|rise|slide|stagger|observe|lazy/i.test(r.selector) || r.decls.some((d) => d.prop === 'transform' && /translate|scale/.test(d.value));
2425
+ if (!revealish) continue;
2426
+ const anim = r.decls.find((d) => d.prop === 'animation' || d.prop === 'animation-name');
2427
+ const animName = anim ? anim.value.split(/\s+/).find((w) => animatedNames.has(w)) : null;
2428
+ if (animName && animatesVisible(animName)) continue;
2429
+ const key = r.selector;
2430
+ if (seen.has(key)) continue; seen.add(key);
2431
+ report(62, 'FAIL', r.file, hidden.line, `${r.selector} is hidden at rest (${hidden.prop}: ${hidden.value}) and depends on a script to appear`, 'Visible by default: scope the hidden state under a .js class the script adds, or animate from visible with @starting-style');
2432
+ }
2433
+ }
2434
+
2435
+ const TEXT_TAGS = new Set(['p', 'span', 'li', 'small', 'a', 'div', 'figcaption', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'dd', 'dt', 'cite', 'strong', 'em', 'b', 'label', 'button', 'summary']);
2436
+ function check63(ctx) {
2437
+ for (const doc of ctx.docs) {
2438
+ const dotLines = new Set(); const arrowLines = new Set(); let dotCount = 0;
2439
+ const tickerRanges = [];
2440
+ for (let i = 0; i < doc.tags.length; i++) {
2441
+ const t = doc.tags[i];
2442
+ if (t.close || t.selfClosing || !/marquee|ticker|tape|crawl/i.test(getAttr(t.attrs, 'class') || '')) continue;
2443
+ const inner = innerOf(doc, i);
2444
+ if (inner) tickerRanges.push([t.pos, inner.endPos]);
2445
+ }
2446
+ for (let i = 0; i < doc.tags.length; i++) {
2447
+ const t = doc.tags[i];
2448
+ if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name)) continue;
2449
+ const cls = getAttr(t.attrs, 'class') || '';
2450
+ if (/marquee|ticker|tape|crawl/i.test(cls) || tickerRanges.some(([a, b]) => t.pos > a && t.pos < b)) continue;
2451
+ const inner = innerOf(doc, i);
2452
+ if (!inner) continue;
2453
+ if (/<(p|div|li|section|article|ul|ol|table)\b/i.test(inner.inner)) continue;
2454
+ const text = plainText(inner.inner);
2455
+ if (!text) continue;
2456
+ const dots = (text.match(/\s[·•]\s/g) || []).length;
2457
+ if (dots >= 2 && !dotLines.has(t.line)) { dotLines.add(t.line); report(63, 'FAIL', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, 'Line breaks, columns or hairlines; one dot in a byline at most'); }
2458
+ if ((t.name === 'a' || t.name === 'button') && /(\u2192|->|\u203a|\u00bb|\u27f6)\s*$/.test(text) && !arrowLines.has(t.line)) { arrowLines.add(t.line); report(63, 'FAIL', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}"`, 'A verb as the label; the link already says it goes somewhere'); }
2459
+ if (/^[A-Z]{3,}\s+-\s+\S/.test(text) && text.length < 60) report(63, 'FAIL', doc.file, t.line, `WORD - fragment label "${trunc(text, 40)}"`, 'Say the thing as a sentence or a heading');
2460
+ }
2461
+ for (let i = 0; i < doc.tags.length; i++) {
2462
+ const t = doc.tags[i];
2463
+ if (t.close || !/^(span|i|b|em|s)$/.test(t.name)) continue;
2464
+ const cls = getAttr(t.attrs, 'class') || '';
2465
+ if (!/(^|\s)(dot|status-dot|indicator|bullet|pip|blip)(\s|$|__|-)/i.test(cls)) continue;
2466
+ if (/aria-live|role\s*=\s*"status"/i.test(t.attrs)) continue;
2467
+ const inner = innerOf(doc, i);
2468
+ if (inner && plainText(inner.inner)) continue;
2469
+ dotCount++;
2470
+ if (dotCount === 3) report(63, 'FAIL', doc.file, t.line, `decorative status dots (${cls.trim()}) repeated across the page`, 'Delete them; a dot means live state, and only where it is one');
2471
+ }
2472
+ }
2473
+ }
2474
+
2475
+ function check64(ctx) {
2476
+ if (monoIsMaterial(ctx)) return;
2477
+ const monoFam = (v) => /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i.test(firstFamily(resolveVars(v, ctx.tokens)));
2478
+ const LABELISH = /label|caption|meta|eyebrow|kicker|tag\b|badge|chip|small|folio|footnote|legend|\bth\b|thead|figcaption|overline|dateline|byline|credit|note\b/i;
2479
+ const DATAISH = /code|pre\b|kbd|samp|num|figure|stat|price|value|data|tabular|counter|timer|clock|coord|amount|total|score|version|hash|\bid\b|sku|time|date|hour|utc|eta|lot\b|edition|ref\b|dimension|measure|reading|plate-?no|tag-?no|url|uri\b|path|endpoint|command|cmd|snippet|install/i;
2480
+ const seen = new Set();
2481
+ for (const r of ctx.rules) {
2482
+ if (r.keyframes || r.selector === '@font-face' || isTokenRule(r)) continue;
2483
+ if (!LABELISH.test(r.selector) || DATAISH.test(r.selector)) continue;
2484
+ const fam = r.decls.find((d) => d.prop === 'font-family' || (d.prop === 'font' && /var\(--font|mono/i.test(d.value)));
2485
+ if (!fam) continue;
2486
+ const raw = fam.prop === 'font' ? (/var\(--font-[\w-]+\)/.exec(fam.value) || [fam.value])[0] : fam.value;
2487
+ if (!monoFam(raw)) continue;
2488
+ const key = baseSelector(r.selector);
2489
+ if (seen.has(key)) continue; seen.add(key);
2490
+ report(64, 'FAIL', r.file, fam.line, `monospace label voice on ${r.selector}`, 'Labels in the body face, a step smaller, sentence case; mono only for code and figures');
2491
+ }
2492
+ }
2493
+
2494
+ function check65(ctx) {
2495
+ const paper = parseColor(resolveVars(ctx.tokens['--color-paper'] || '', ctx.tokens));
2496
+ const accent = parseColor(resolveVars(ctx.tokens['--color-accent'] || '', ctx.tokens));
2497
+ if (!paper || !accent) return;
2498
+ const p = paper.okl || srgbToOklch(paper); const a = accent.okl || srgbToOklch(accent);
2499
+ const cream = p.L >= 0.9 && p.C >= 0.006 && p.H >= 55 && p.H <= 105;
2500
+ const clay = a.C >= 0.08 && a.H >= 15 && a.H <= 55;
2501
+ const disp = firstFamily(resolveVars(ctx.tokens['--font-display'] || '', ctx.tokens));
2502
+ const hcSerif = /bodoni|didot|didone|playfair|fraunces|instrument serif|cormorant|dm serif|libre caslon display|abril|prata|gloock|newsreader|editorial new|gt sectra|canela|freight|yeseva|rozha|boska|melodrama/i.test(disp);
2503
+ const catalogTheme = /theme:\s*(?!custom\b)[A-Z][\w-]*\s*\((?:deal|by request)/.test(ctx.stampText);
2504
+ if (cream && clay && hcSerif && !catalogTheme) report(65, 'FAIL', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `the named look: cream paper (L ${Math.round(p.L * 100)}%, hue ${Math.round(p.H)}) + ${disp} + a clay accent (hue ${Math.round(a.H)})`, 'Move one axis, the accent first; or stamp the theme as asked for by name');
2505
+ }
2506
+
2507
+ function checkF15(ctx) {
2508
+ const widths = new Map();
2509
+ for (const r of ctx.rules) {
2510
+ if (r.keyframes || r.media || !/container|wrap|inner|shell|content|section|page|main/i.test(r.selector)) continue;
2511
+ for (const d of r.decls) if (d.prop === 'max-width') { const px = maxPxIn(resolveVars(d.value, ctx.tokens)); if (px && px >= 900) widths.set(px, r.selector); }
2512
+ }
2513
+ if (widths.size > 1) report('F15', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `${widths.size} container widths on one page (${[...widths.keys()].join(', ')}px)`, 'One container width, applied by the same two-element pattern in every section');
2514
+ const gaps = new Map();
2515
+ for (const r of ctx.rules) {
2516
+ if (r.keyframes || r.media || /hero|masthead|fold|\b(?:app|ui|demo|sample|screen|product|mock|device|console|editor|window)(?:\b|__|--)|\[data-slot/i.test(r.selector)) continue;
2517
+ const cols = r.decls.find((d) => d.prop === 'grid-template-columns' && trackCount(d.value) > 1 && !/^repeat\(\s*auto/.test(d.value.trim()));
2518
+ if (!cols) continue;
2519
+ const gap = r.decls.find((d) => /^(gap|column-gap)$/.test(d.prop));
2520
+ if (gap) { const v = resolveVars(gap.value, ctx.tokens).split(/\s+/).pop(); const px = maxPxIn(v); if (px != null && px >= 24) gaps.set(v, r.selector); }
2521
+ }
2522
+ if (gaps.size > 2) report('F15', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `${gaps.size} different column gaps across multi-column sections (${[...gaps.keys()].join(', ')})`, 'One column gap for every multi-column section');
2523
+ }
2524
+
2525
+ function trackCount(v) {
2526
+ const t = v.replace(/\[[^\]]*\]/g, ' ').trim();
2527
+ const rep = /^repeat\(\s*(\d+)/.exec(t);
2528
+ if (rep) return Number(rep[1]);
2529
+ let depth = 0, n = 0, inTok = false;
2530
+ for (const ch of t) {
2531
+ if (ch === '(') depth++;
2532
+ else if (ch === ')') depth--;
2533
+ if (depth === 0 && /\s/.test(ch)) inTok = false;
2534
+ else if (!inTok) { inTok = true; n++; }
2535
+ }
2536
+ return n;
2537
+ }
2538
+
2539
+ function checkF16(ctx) {
2540
+ if (ctx.tokensOnly || !ctx.tokens['--color-accent']) return;
2541
+ const tw = twGroup(ctx);
2542
+ const missing = [];
2543
+ if (!/::selection/.test(ctx.allCss) && !tw.variants.has('selection')) missing.push('::selection');
2544
+ if (!/scrollbar-color|::-webkit-scrollbar/.test(ctx.allCss) && !tw.scrollbar) missing.push('scrollbar-color');
2545
+ const hasInputs = ctx.docs.some((d) => /<(input|textarea)\b/i.test(d.src));
2546
+ if (hasInputs && !/caret-color/.test(ctx.allCss) && !tw.caret) missing.push('caret-color');
2547
+ if (missing.length) report('F16', 'WARN', ctx.cssFiles[0]?.file || ctx.docs[0]?.file, 1, `browser surfaces unthemed: ${missing.join(', ')}`, 'Theme the parts you did not draw from the palette');
2548
+ }
2549
+
2550
+ const proseRun = (run, pad) => {
2551
+ let r = run;
2552
+ for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad);
2553
+ return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r;
2554
+ };
2555
+ function maskedProse(doc) {
2556
+ if (doc._prose == null) {
2557
+ const pad = (m) => m.replace(/[^\n]/g, ' ');
2558
+ const src = doc.kind && doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (m0, a, run) => a + proseRun(run, pad)) : doc.src;
2559
+ doc._prose = src
2560
+ .replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
2561
+ .replace(/<!--[\s\S]*?-->/g, pad)
2562
+ .replace(/<[^>]*>/g, pad);
2563
+ }
2564
+ return doc._prose;
2565
+ }
2566
+
2567
+ function maxPxIn(value) {
2568
+ let best = null;
2569
+ for (const m of String(value).matchAll(/(-?\d+(?:\.\d+)?)(px|rem|em)/g)) {
2570
+ const px = m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
2571
+ if (best == null || px > best) best = px;
2572
+ }
2573
+ return best;
2574
+ }
2575
+
2576
+ function baseSelector(sel) {
2577
+ return sel.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\s+/g, ' ').trim();
2578
+ }
2579
+
2580
+ function checkF1(ctx) {
2581
+ const dq = /(?:"|&quot;|&#0?34;)(?=[A-Za-z])|[A-Za-z](?:"|&quot;|&#0?34;)/g;
2582
+ const ap = /[A-Za-z](?:'|&#0?39;|&apos;)(?=[A-Za-z])/g;
2583
+ for (const doc of ctx.docs) {
2584
+ const prose = maskedProse(doc);
2585
+ const hits = [...prose.matchAll(dq), ...prose.matchAll(ap)].sort((a, b) => a.index - b.index);
2586
+ if (!hits.length) continue;
2587
+ const around = prose.slice(Math.max(0, hits[0].index - 16), hits[0].index + 16).replace(/\s+/g, ' ').trim();
2588
+ report('F1', 'WARN', doc.file, doc.lineOf(hits[0].index),
2589
+ `straight quote in prose x${hits.length} (first: ${trunc(around, 40)})`, 'Use typographic quotes: \u201C \u201D \u2019');
2590
+ }
2591
+ }
2592
+
2593
+ function checkF2(ctx) {
2594
+ for (const doc of ctx.docs) {
2595
+ const prose = maskedProse(doc);
2596
+ const hits = [...prose.matchAll(/\.{3,}/g)];
2597
+ if (!hits.length) continue;
2598
+ report('F2', 'WARN', doc.file, doc.lineOf(hits[0].index),
2599
+ `three-period ellipsis in prose x${hits.length}`, 'Use a true ellipsis character (\u2026)');
2600
+ }
2601
+ }
2602
+
2603
+ function checkF3(ctx) {
2604
+ const H12 = /(^|[\s,>+~(])h[12]\b/i;
2605
+ const targets = [];
2606
+ let satisfied = false;
2607
+ for (const r of mergedDeclsBySelector(ctx)) {
2608
+ const isH12 = H12.test(r.selector);
2609
+ const isDisplayClass = HEADING_SEL.test(r.selector)
2610
+ && r.decls.some((d) => d.prop === 'font-size' && (maxPxIn(resolveVars(d.value, ctx.tokens)) ?? 0) >= 40);
2611
+ if (!isH12 && !isDisplayClass) continue;
2612
+ targets.push(r);
2613
+ if (r.decls.some((d) => /^text-wrap(-style)?$/.test(d.prop) && /balance/.test(d.value))) satisfied = true;
2614
+ }
2615
+ if (targets.length && !satisfied) {
2616
+ report('F3', 'WARN', targets[0].file, targets[0].line,
2617
+ `no text-wrap: balance on any display head (${targets.length} head rule(s), e.g. ${trunc(targets[0].selector, 32)})`,
2618
+ 'Add text-wrap: balance to h1/h2-level heads');
2619
+ }
2620
+ }
2621
+
2622
+ function checkF4(ctx) {
2623
+ const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i;
2624
+ const filled = new Map();
2625
+ for (const r of mergedDeclsBySelector(ctx)) {
2626
+ if (isTokenRule(r) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(r.selector)) continue;
2627
+ const first = baseSelector(r.selector.split(',')[0]);
2628
+ if (!BTN.test(first)) continue;
2629
+ for (const d of r.decls) {
2630
+ if (!/^background(-color)?$/.test(d.prop)) continue;
2631
+ const rv = resolveVars(d.value, ctx.tokens);
2632
+ if (/transparent|none/i.test(rv)) continue;
2633
+ if (/var\(\s*--color-(?!paper|rule|muted|focus)[\w-]+/.test(d.value) && !filled.has(first)) filled.set(first, { r, d });
2634
+ }
2635
+ }
2636
+ if (filled.size >= 2) {
2637
+ const sels = [...filled.keys()];
2638
+ const second = filled.get(sels[1]);
2639
+ report('F4', 'WARN', second.r.file, second.d.line,
2640
+ `${filled.size} filled primary-button recipes: ${trunc(sels.join(' + '), 52)}`,
2641
+ 'One filled primary per page; demote the rest to outline/quiet variants');
2642
+ }
2643
+ }
2644
+
2645
+ function checkF5(ctx) {
2646
+ for (const r of mergedDeclsBySelector(ctx)) {
2647
+ if (isTokenRule(r) || !/icon|\bico\b/i.test(r.selector)) continue;
2648
+ const radius = r.decls.find((d) => d.prop === 'border-radius');
2649
+ if (!radius) continue;
2650
+ const rv = resolveVars(radius.value, ctx.tokens);
2651
+ const pct = /(\d+(?:\.\d+)?)%/.exec(rv);
2652
+ const round = (pct && parseFloat(pct[1]) >= 50) || ((maxPxIn(rv) ?? 0) >= 999);
2653
+ if (!round) continue;
2654
+ const bg = r.decls.find((d) => /^background(-color)?$/.test(d.prop)
2655
+ && !/^(none|transparent)$/i.test(resolveVars(d.value, ctx.tokens).trim()));
2656
+ if (!bg) continue;
2657
+ const small = r.decls.some((d) => (d.prop === 'width' || d.prop === 'height')
2658
+ && (pxOf(resolveVars(d.value, ctx.tokens)) ?? 999) <= 64);
2659
+ if (small) {
2660
+ report('F5', 'WARN', r.file, bg.line, `icon-in-coloured-circle coin: ${trunc(r.selector, 40)}`,
2661
+ 'Drop the tinted coin; set the icon at text size in currentColor');
2662
+ }
2663
+ }
2664
+ }
2665
+
2666
+ function checkF6(ctx) {
2667
+ const NUM_SEL = /\b(stat(?!ement)|price|table|figure|count|num)[\w-]*/i;
2668
+ const hasTnum = (r) => r.decls.some((d) => d.prop === 'font-variant-numeric'
2669
+ || (d.prop === 'font-feature-settings' && /tnum/.test(d.value)));
2670
+ if (ctx.rules.some((r) => /^(html|body|:root)\b/i.test(r.selector.trim()) && hasTnum(r))) return;
2671
+ const bySel = mergedDeclsBySelector(ctx);
2672
+ const covered = new Set(bySel.filter(hasTnum).map((r) => baseSelector(r.selector)));
2673
+ const warned = new Set();
2674
+ for (const r of bySel) {
2675
+ if (isTokenRule(r) || !NUM_SEL.test(r.selector)) continue;
2676
+ const base = baseSelector(r.selector);
2677
+ if (warned.has(base) || covered.has(base)) continue;
2678
+ const fs2 = r.decls.find((d) => d.prop === 'font-size');
2679
+ if (!fs2 || (maxPxIn(resolveVars(fs2.value, ctx.tokens)) ?? 0) < 24) continue;
2680
+ warned.add(base);
2681
+ report('F6', 'WARN', r.file, fs2.line,
2682
+ `numeric selector ${trunc(base, 36)} at display size without tabular figures`,
2683
+ 'Add font-variant-numeric: tabular-nums');
2684
+ }
2685
+ }
2686
+
2687
+ function checkF7(ctx) {
2688
+ const byKey = new Map();
2689
+ for (const r of mergedDeclsBySelector(ctx)) {
2690
+ if (isTokenRule(r) || /::(before|after)/.test(r.selector)) continue;
2691
+ const get = (re) => {
2692
+ const d = r.decls.filter((x) => re.test(x.prop)).pop();
2693
+ return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : '';
2694
+ };
2695
+ const border = get(/^border$/)
2696
+ || [get(/^border-width$/), get(/^border-style$/), get(/^border-color$/)].filter(Boolean).join(' ');
2697
+ const radius = get(/^border-radius$/);
2698
+ const surface = get(/^background(-color)?$/) || '';
2699
+ const shadow = get(/^box-shadow$/);
2700
+ if (!radius || (!border && !surface && !shadow)) continue;
2701
+ const key = [border, radius, surface, shadow].join(' | ');
2702
+ if (!byKey.has(key)) byKey.set(key, { sels: new Set(), r });
2703
+ for (const part of r.selector.split(',')) byKey.get(key).sels.add(baseSelector(part));
2704
+ }
2705
+ for (const v of byKey.values()) {
2706
+ if (v.sels.size >= 6) {
2707
+ report('F7', 'WARN', v.r.file, v.r.line,
2708
+ `same container recipe on ${v.sels.size} selectors (${trunc([...v.sels].slice(0, 4).join(', '), 48)}...)`,
2709
+ 'Vary weight, radius, or fill by role; not every box is the same box');
2710
+ }
2711
+ }
2712
+ }
2713
+
2714
+ function checkF8(ctx) {
2715
+ const famOf = (sel) => {
2716
+ const m = /\.([a-z][\w-]*)/i.exec(sel) || /(^|[\s>+~])([a-z][\w-]*)/i.exec(sel);
2717
+ const name = m ? (m[1] || m[2]) : sel;
2718
+ return name.split('__')[0].split('--')[0].toLowerCase();
2719
+ };
2720
+ const NAVISH = /^(nav|header|topbar|masthead|banner)$/;
2721
+ const timeline = new Map(), sticky = new Map(), animated = new Set();
2722
+ for (const r of ctx.rules) {
2723
+ if (r.keyframes || isTokenRule(r)) continue;
2724
+ for (const d of r.decls) {
2725
+ if (d.prop === 'animation-timeline' && !/^(none|auto)$/i.test(d.value.trim())
2726
+ && !timeline.has(famOf(r.selector))) timeline.set(famOf(r.selector), { r, d });
2727
+ if (d.prop === 'animation' || d.prop === 'animation-name') animated.add(famOf(r.selector));
2728
+ if (d.prop === 'position' && /sticky/i.test(d.value)) sticky.set(famOf(r.selector), { r, d });
2729
+ }
2730
+ }
2731
+ const families = new Map(timeline);
2732
+ for (const [fam, v] of sticky) {
2733
+ if (!families.has(fam) && animated.has(fam) && !NAVISH.test(fam)) families.set(fam, v);
2734
+ }
2735
+ if (families.size > 1) {
2736
+ const names = [...families.keys()];
2737
+ const second = families.get(names[1]);
2738
+ report('F8', 'WARN', second.r.file, second.d.line,
2739
+ `${families.size} scroll-choreography patterns on one page (${trunc(names.join(', '), 44)})`,
2740
+ 'Budget is one scroll pattern per page (scroll-choreography.md); cut the rest');
2741
+ }
2742
+ }
2743
+
2744
+ function checkF9(ctx) {
2745
+ const FAKE = /(i\.pravatar\.cc|pravatar\.cc|api\.dicebear\.com|dicebear\.com|randomuser\.me|thispersondoesnotexist\.com|ui-avatars\.com)/i;
2746
+ const UNSPLASH = /https?:\/\/[^"'()\s]*unsplash\.com[^"'()\s]*/gi;
2747
+ const PERSONY = /person|face|portrait|people|headshot/i;
2748
+ const scan = (text, file, lineAt) => {
2749
+ const f = FAKE.exec(text);
2750
+ if (f) report('F9', 'FAIL', file, lineAt(f.index), `fake-human avatar service ${f[1]}`,
2751
+ 'Drop the face, or use the real person\'s own photo; a generated stranger is fabricated social proof');
2752
+ for (const m of text.matchAll(UNSPLASH)) {
2753
+ if (PERSONY.test(m[0])) report('F9', 'WARN', file, lineAt(m.index),
2754
+ `stock person-keyword URL ${trunc(m[0], 44)}`,
2755
+ 'Faces as social proof need real people; a stock face is a stranger');
2756
+ }
2757
+ };
2758
+ for (const doc of ctx.docs) scan(doc.src, doc.file, (i) => doc.lineOf(i));
2759
+ for (const c of ctx.cssFiles) scan(c.raw, c.file, (i) => c.raw.slice(0, i).split('\n').length);
2760
+ }
2761
+
2762
+ function fontSizePxOf(r, ctx) {
2763
+ const fs = r.decls.find((d) => d.prop === 'font-size');
2764
+ if (fs) return pxOf(resolveVars(fs.value, ctx.tokens));
2765
+ const f = r.decls.find((d) => d.prop === 'font');
2766
+ if (!f) return null;
2767
+ const m = /(?:^|\s)(\d+(?:\.\d+)?(?:px|rem|em))(?:\/[\d.]+(?:px|rem|em)?)?\s/.exec(resolveVars(f.value, ctx.tokens) + ' ');
2768
+ return m ? pxOf(m[1]) : null;
2769
+ }
2770
+
2771
+ const LABEL67 = /label|caption|meta|eyebrow|kicker|tag\b|badge|chip|small|folio|footnote|legend|\bth\b|thead|figcaption|overline|dateline|byline|credit|note\b|\bnav\b|menu|link|pill|status|unit|smallcaps|count|placard/i;
2772
+ const CONTROL67 = /btn|button|cta|\.sign\b|plate|control/i;
2773
+ const EXEMPT67_NAME = /^(wordmark|marquee|ticker|logo|brand|abbr|logo-?(mark|type|text)|brand-?(mark|name|type))$/i;
2774
+ const EXEMPT67_PART = /(^|[-_])(wordmark|marquee|ticker|logotype|logo|brand)([-_]|$)|(^|[-_])(slab|nav|site|head|header|masthead|bar)[-_]+mark([-_]|$)/i;
2775
+ const exemptName67 = (n) => !!n && (EXEMPT67_NAME.test(n.split('--')[0].split('__').pop()) || EXEMPT67_PART.test(n));
2776
+ const SCROLLER67 = /(^|[-_])(marquee|ticker)([-_]|$)/i;
2777
+ function exempt67(sel) {
2778
+ return sel.split(',').some((part) => {
2779
+ const comps = baseSelector(part).split(/[\s>+~]+/).filter(Boolean);
2780
+ const comp = comps[comps.length - 1] || '';
2781
+ const names = (comp.match(/\.[a-zA-Z_][\w-]*/g) || []).map((c) => c.slice(1));
2782
+ names.push((/^[a-zA-Z][\w-]*/.exec(comp) || [''])[0]);
2783
+ if (names.some(exemptName67)) return true;
2784
+ return comps.slice(0, -1).some((c) => (c.match(/\.[a-zA-Z_][\w-]*/g) || []).some((x) => SCROLLER67.test(x.slice(1))));
2785
+ });
2786
+ }
2787
+ function inherited67(bySel, sel, prop) {
2788
+ const base = baseSelector(sel);
2789
+ let best = null, bestLen = 0;
2790
+ for (const o of bySel) {
2791
+ if (/:hover|:focus|:active/.test(o.selector) || (o.media && /max-width/i.test(o.media))) continue;
2792
+ for (const part of o.selector.split(',')) {
2793
+ const ob = baseSelector(part);
2794
+ if (!ob || ob === base || ob.length <= bestLen || !base.startsWith(ob) || !/[\s>+~]/.test(base[ob.length] || '')) continue;
2795
+ const d = o.decls.filter((x) => x.prop === prop).pop();
2796
+ if (d) { best = d.value; bestLen = ob.length; }
2797
+ }
2798
+ }
2799
+ return best;
2800
+ }
2801
+ const isSmallCaps67 = (prop, v) => (/^font-variant(-caps)?$/.test(prop) && /\b(all-)?(small|petite)-caps\b/.test(v))
2802
+ || (prop === 'font-feature-settings' && /\b(smcp|c2sc)\b/.test(v));
2803
+ function check67(ctx) {
2804
+ const bySel = mergedDeclsBySelector(ctx);
2805
+ const displayish = (r) => HEADING_SEL.test(r.selector) || /display|title|headline|bill|poster|banner/i.test(r.selector) || r.decls.some((d) => (d.prop === 'font-family' || d.prop === 'font') && /--font-display/.test(d.value)) || (fontSizePxOf(r, ctx) || 0) >= 28;
2806
+ const upperDisplay = bySel.some((r) => displayish(r) && !LABEL67.test(r.selector) && r.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value)));
2807
+ const isUpper = (v) => v != null && /uppercase/.test(resolveVars(v, ctx.tokens));
2808
+ const seen = new Set();
2809
+ for (const r of bySel) {
2810
+ if (isTokenRule(r) || exempt67(r.selector) || /::first-letter\s*(,|$)/.test(r.selector)) continue;
2811
+ const ownTT = r.decls.filter((d) => d.prop === 'text-transform').pop();
2812
+ const ownUpper = ownTT ? isUpper(ownTT.value) : null;
2813
+ const ownSmallCaps = r.decls.some((d) => isSmallCaps67(d.prop, resolveVars(d.value, ctx.tokens)));
2814
+ const px = fontSizePxOf(r, ctx);
2815
+ const ownEm = trackingEmOf(ctx, r.decls, px || 16);
2816
+ const ownSignal = ownUpper === true || ownSmallCaps || (ownEm != null && ownEm > 0.03);
2817
+ if (!ownSignal) continue;
2818
+ const upper = ownUpper != null ? ownUpper : isUpper(inherited67(bySel, r.selector, 'text-transform'));
2819
+ const smallCaps = ownSmallCaps || ['font-variant', 'font-variant-caps', 'font-feature-settings'].some((prop) => {
2820
+ const v = inherited67(bySel, r.selector, prop);
2821
+ return v != null && isSmallCaps67(prop, resolveVars(v, ctx.tokens));
2822
+ });
2823
+ let em = ownEm;
2824
+ if (em == null) {
2825
+ const v = inherited67(bySel, r.selector, 'letter-spacing');
2826
+ if (v != null) em = trackingEmOf(ctx, [{ prop: 'letter-spacing', value: v }], px || 16);
2827
+ }
2828
+ const tracked = em != null && em > 0.03;
2829
+ const subject = r.selector.split(',').map((p) => baseSelector(p).split(/[\s>+~]+/).filter(Boolean).pop() || '');
2830
+ const bigToken = r.decls.some((d) => d.prop === 'font-size' && /--text-(display|section|title)|clamp\(\s*(?:[2-9]|\d{2,})(?:\.\d+)?rem/.test(d.value));
2831
+ const labelish = subject.some((s) => LABEL67.test(s)) ? !((px != null && px >= 28) || bigToken)
2832
+ : LABEL67.test(r.selector) && !subject.every((s) => /^h[1-6]\b/i.test(s)) && (px == null || px < 20);
2833
+ const control = CONTROL67.test(r.selector);
2834
+ const heading = HEADING_SEL.test(r.selector) && !labelish;
2835
+ if (heading && !control) continue;
2836
+ const key = baseSelector(r.selector);
2837
+ if (seen.has(key)) continue;
2838
+ const small = px != null && px <= 15;
2839
+ if (control && !labelish) {
2840
+ if (!upper && !smallCaps) { if (!(tracked && small)) continue; }
2841
+ else if (upperDisplay && (em == null || em <= 0.045)) continue;
2842
+ seen.add(key);
2843
+ report(67, 'FAIL', r.file, r.line, upperDisplay ? `${r.selector}: uppercase control tracked ${em}em` : `${r.selector}: uppercase control on a page whose display is not uppercase`, 'Controls follow the display voice: sentence case here, or tracking 0.04em at most where the display itself is uppercase');
2844
+ continue;
2845
+ }
2846
+ const caps = upper || smallCaps;
2847
+ if ((caps && (labelish || smallCaps || tracked || small)) || (tracked && (labelish || small))) {
2848
+ seen.add(key);
2849
+ const what = smallCaps ? 'small caps' : upper ? 'uppercase' : 'letter-spaced';
2850
+ report(67, 'FAIL', r.file, r.line, `${r.selector}: ${what}${tracked ? ` tracked ${em}em` : ''}${px != null ? ` at ${Math.round(px)}px` : ''} in a label role`, 'Labels, captions, table heads, nav links and meta are the body face (or the theme\'s label face), a step smaller, sentence case, untracked');
2851
+ }
2852
+ }
2853
+ check67Html(ctx);
2854
+ }
2855
+
2856
+ const CAPS_TAGS = /^(p|span|small|a|li|div|figcaption|td|th|dt|dd|label|time|cite|strong|b|em|button|summary)$/;
2857
+ function check67Html(ctx) {
2858
+ for (const doc of ctx.docs) {
2859
+ const anc = ancestorMap(doc);
2860
+ const seen = new Set();
2861
+ for (let i = 0; i < doc.tags.length; i++) {
2862
+ const t = doc.tags[i];
2863
+ if (t.close || t.selfClosing || !CAPS_TAGS.test(t.name)) continue;
2864
+ const cls = getAttr(t.attrs, 'class') || '';
2865
+ if (cls.split(/\s+/).some(exemptName67)) continue;
2866
+ if ((anc.get(i) || []).some((n) => /^(svg|pre|code|abbr|kbd|samp)$/.test(n))) continue;
2867
+ const inner = innerOf(doc, i);
2868
+ if (!inner || /<(p|div|ul|ol|h[1-6]|table|section|article|svg|img|abbr|code|kbd)\b/i.test(inner.inner)) continue;
2869
+ const text = plainText(inner.inner);
2870
+ const letters = text.replace(/[^A-Za-z]/g, '');
2871
+ if (letters.length < 5 || capsRatio(text) < 0.95 || /^[A-Z]{2,5}$/.test(text) || /^\(?\s*(CC0|CC BY(-SA|-NC|-ND)*( [0-9.]+)?|PD|GFDL)\b/.test(text)) continue;
2872
+ const decls = styleFor(ctx, cls, t.name);
2873
+ const px = fontPxOf(ctx, decls);
2874
+ const em = trackingEmOf(ctx, decls, px || 16);
2875
+ const tracked = em != null && em > 0.03;
2876
+ if (!((px != null && px <= 15) || tracked)) continue;
2877
+ const key = cls || t.name;
2878
+ if (seen.has(key)) continue;
2879
+ seen.add(key);
2880
+ report(67, 'FAIL', doc.file, t.line, `"${trunc(text, 32)}" typed in capitals${px != null ? ` at ${Math.round(px)}px` : ''}${tracked ? ` tracked ${em}em` : ''} (<${t.name}${cls ? ` class="${trunc(cls, 24)}"` : ''}>)`, 'Sentence case in the body face; capitals survive only as a wordmark or as the display voice');
2881
+ }
2882
+ }
2883
+ }
2884
+
2885
+ const ACCENT38 = /(?:^|[\s,])((?:h[1-3]|\.display|\.headline|\.title|[.\w-]*__(?:title|display|headline))[^\s,>+~]*)\s*(?:>|\s)\s*(em|i|b|strong|span|mark|\.[\w-]+)(?::nth-child\([^)]*\))?(?:::?[\w-]+)?\s*$/i;
2886
+ const INHERITED38 = /^(inherit|currentcolor|unset|initial|normal)$/i;
2887
+ function accentedHeads(ctx) {
2888
+ if (ctx._acc38) return ctx._acc38;
2889
+ const out = [];
2890
+ const seen = new Set();
2891
+ for (const r of ctx.rules) {
2892
+ if (r.keyframes || isTokenRule(r)) continue;
2893
+ const m = ACCENT38.exec(r.selector);
2894
+ if (!m) continue;
2895
+ const marked = r.decls.some((d) =>
2896
+ (/^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow)$/.test(d.prop) && !INHERITED38.test(d.value.trim()))
2897
+ || (d.prop === 'font-style' && !INHERITED38.test(d.value.trim())));
2898
+ if (!marked || seen.has(r.selector)) continue;
2899
+ seen.add(r.selector);
2900
+ out.push({ r, head: m[1] });
2901
+ }
2902
+ const marks = (r) => r.decls.some((d) => (/^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow|font-style)$/.test(d.prop) && !INHERITED38.test(d.value.trim())));
2903
+ for (const doc of ctx.docs) {
2904
+ for (let i = 0; i < doc.tags.length; i++) {
2905
+ const t = doc.tags[i];
2906
+ if (t.close || !/^h[1-3]$/.test(t.name)) continue;
2907
+ const inner = innerOf(doc, i);
2908
+ if (!inner) continue;
2909
+ const headLen = plainText(inner.inner).replace(/\s+/g, '').length || 1;
2910
+ const spans = [...inner.inner.matchAll(/<(span|em|strong|b|i|mark)\b[^>]*\bclass\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/\1>/gi)];
2911
+ const cover = (cls) => spans.filter((x) => x[2].split(/\s+/).includes(cls)).reduce((n, x) => n + plainText(x[3]).replace(/\s+/g, '').length, 0) / headLen;
2912
+ for (const m of spans) {
2913
+ if (!/[A-Za-z0-9]/.test(plainText(m[3]))) continue;
2914
+ for (const cls of m[2].split(/\s+/).filter(Boolean)) {
2915
+ if (cover(cls) >= 0.8) continue;
2916
+ for (const r of ctx.rules) {
2917
+ if (r.keyframes || isTokenRule(r) || seen.has(r.selector)) continue;
2918
+ if (!r.selector.split(',').some((part) => !/::?(before|after)\b/.test(part) && baseSelector(part).trim() === `.${cls}`)) continue;
2919
+ if (r.decls.some((d) => (d.prop === 'display' && /^(block|flex|grid|table)/.test(d.value.trim())) || (d.prop === 'position' && /absolute|fixed/.test(d.value)))) continue;
2920
+ if (!marks(r)) continue;
2921
+ const headDecls = styleFor(ctx, getAttr(t.attrs, 'class'), t.name, true);
2922
+ const norm = (v) => String(v || '').replace(/\s+/g, ' ').trim().toLowerCase();
2923
+ const own = (prop) => declValue(ctx, headDecls, prop) ?? (prop === 'color' ? resolveVars('var(--color-ink)', ctx.tokens) : null);
2924
+ const marking = r.decls.filter((d) => /^(color|background|background-color|-webkit-text-stroke|font-weight|text-decoration|text-decoration-color|border-bottom|box-shadow|font-style)$/.test(d.prop) && !INHERITED38.test(d.value.trim()));
2925
+ if (marking.length && marking.every((d) => /^(color|font-weight)$/.test(d.prop) && own(d.prop) != null && norm(resolveVars(d.value, ctx.tokens)) === norm(own(d.prop)))) continue;
2926
+ seen.add(r.selector);
2927
+ out.push({ r, head: t.name });
2928
+ }
2929
+ }
2930
+ }
2931
+ }
2932
+ }
2933
+ ctx._acc38 = out;
2934
+ return out;
2935
+ }
2936
+ function accentedWordInHero(ctx) {
2937
+ const heads = accentedHeads(ctx);
2938
+ if (!heads.length) return true;
2939
+ const sels = new Set(heads.map((h) => baseSelector(h.r.selector)));
2940
+ if (sels.size > 1) return `the accented word sits on ${sels.size} selectors (${trunc([...sels].join(', '), 48)}); the signature appears once`;
2941
+ const { r, head } = heads[0];
2942
+ if (/hero/i.test(r.selector) || /^h1\b/i.test(head)) return true;
2943
+ const cls = (/\.([\w-]+)/.exec(head) || [])[1];
2944
+ if (!cls || !ctx.docs.length) return true;
2945
+ let count = 0, aboveFirstH2 = false;
2946
+ for (const doc of ctx.docs) {
2947
+ let h2Seen = false;
2948
+ for (const t of doc.tags) {
2949
+ if (t.close) continue;
2950
+ if (t.name === 'h2') h2Seen = true;
2951
+ if ((getAttr(t.attrs, 'class') || '').split(/\s+/).includes(cls)) { count++; if (!h2Seen) aboveFirstH2 = true; }
2952
+ }
2953
+ }
2954
+ if (count === 1 && aboveFirstH2) return true;
2955
+ return count !== 1 ? `.${cls} appears ${count} times in the markup; the signature appears once, in the hero` : `.${cls} sits below the first h2, outside the hero`;
2956
+ }
2957
+ function check38c(ctx) {
2958
+ for (const { r } of accentedHeads(ctx)) {
2959
+ report('38iii', 'FAIL', r.file, r.line, `accented word inside a heading (${r.selector})`, 'Set the whole head one way: a line break, a second line at a second size, or the whole head in the accent. Guard: the theme names the coloured word as its signature and it appears once on the page, in the hero');
2960
+ }
2961
+ }
2962
+
2963
+ function heroIndexOf(doc, h1Idx) {
2964
+ for (let i = h1Idx - 1; i >= 0; i--) {
2965
+ const t = doc.tags[i];
2966
+ if (t.close || !/^(section|header)$/.test(t.name)) continue;
2967
+ const inner = innerOf(doc, i);
2968
+ if (inner && inner.endPos > doc.tags[h1Idx].pos) return i;
2969
+ }
2970
+ return -1;
2971
+ }
2972
+ const wordsOf = (t) => plainText(t).split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w));
2973
+
2974
+ const TELLS19 = [
2975
+ [/\bwhether you'?re\b/i, 'filler opener'],
2976
+ [/\bwe'?ve got you covered\b/i, 'cliche'],
2977
+ [/\bsay goodbye to\b/i, 'cliche'],
2978
+ [/\bno credit card required\b/i, 'template line'],
2979
+ [/\beverything you need to\b/i, 'cliche'],
2980
+ [/\ball in one place\b/i, 'cliche'],
2981
+ [/\blorem ipsum\b/i, 'placeholder text'],
2982
+ [/\ball rights reserved\b/i, 'template footer line'],
2983
+ [/\bmade with\s*(?:♥|❤|\u{1f9e1}|\u{1f49b}|\u{1f49a}|\u{1f499}|\u{1f49c}|\u{1f5a4}|\u{1f90d}|&hearts;|&#9829;|&#10084;|<3)/iu, 'made with a heart'],
2984
+ ];
2985
+ function check19Tells(ctx) {
2986
+ for (const doc of ctx.docs) {
2987
+ const prose = maskedProse(doc);
2988
+ const lines = new Set();
2989
+ const tell = (idx, what, text) => {
2990
+ const line = doc.lineOf(idx);
2991
+ if (lines.has(line)) return;
2992
+ lines.add(line);
2993
+ report(19, what === 'template footer line' ? 'WARN' : 'FAIL', doc.file, line, `${what}: "${trunc(text, 40)}"`, what === 'template footer line' ? 'A stock line with no legal effect; cut it unless the owner wants it' : 'Say what the product does, in its own nouns');
2994
+ };
2995
+ for (const [re, what] of TELLS19) {
2996
+ const m = re.exec(prose);
2997
+ if (m) tell(m.index, what, m[0]);
2998
+ }
2999
+ const stock = [];
3000
+ for (let i = 0; i < doc.tags.length; i++) {
3001
+ const t = doc.tags[i];
3002
+ if (t.close || t.name !== 'a') continue;
3003
+ const label = plainText(innerOf(doc, i)?.inner || '').replace(/[\u2192\u203a\u00bb]|->/g, '').trim();
3004
+ if (/^(learn more|read more|find out more|click here|more info|see more)$/i.test(label)) stock.push({ t, label });
3005
+ }
3006
+ if (stock.length >= 2) report(19, 'WARN', doc.file, stock[0].t.line, `stock link label "${stock[0].label}" x${stock.length}`, 'Say where each link goes: the thing it opens, in its own noun');
3007
+ for (let i = 0; i < doc.tags.length; i++) {
3008
+ const t = doc.tags[i];
3009
+ if (t.close || !/^h[1-6]$/.test(t.name)) continue;
3010
+ const text = plainText(innerOf(doc, i)?.inner || '');
3011
+ if (/^Meet\s+[A-Z][\w-]*[.!]?$/.test(text)) tell(t.pos, 'the "Meet <Name>" heading', text);
3012
+ else if (/^[A-Z][a-z]+\.\s+[A-Z][a-z]+\.\s+[A-Z][a-z]+\.$/.test(text)) tell(t.pos, 'the three-word triad heading', text);
3013
+ }
3014
+ }
3015
+ }
3016
+
3017
+ function check58Static(ctx) {
3018
+ for (const doc of ctx.docs) {
3019
+ const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
3020
+ if (h1 >= 0) {
3021
+ const words = wordsOf(innerOf(doc, h1)?.inner || '');
3022
+ if (words.length > 7) report(58, 'WARN', doc.file, doc.tags[h1].line, `h1 runs ${words.length} words ("${trunc(words.join(' '), 40)}")`, 'Seven words or fewer, 50 characters or fewer');
3023
+ const heroIdx = heroIndexOf(doc, h1);
3024
+ const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null;
3025
+ if (hero) {
3026
+ for (let i = h1 + 1; i < doc.tags.length && doc.tags[i].pos < hero.endPos; i++) {
3027
+ const t = doc.tags[i];
3028
+ if (t.close || t.name !== 'p') continue;
3029
+ const pw = wordsOf(innerOf(doc, i)?.inner || '');
3030
+ if (pw.length > 12) report(58, 'WARN', doc.file, t.line, `hero paragraph runs ${pw.length} words`, 'One sentence, twelve words or fewer');
3031
+ break;
3032
+ }
3033
+ }
3034
+ }
3035
+ const sections = doc.tags.filter((t) => !t.close && t.name === 'section').length;
3036
+ const fullPage = sections >= 5 || doc.tags.some((t) => !t.close && t.name === 'footer');
3037
+ if (!fullPage || doc.kind !== 'html') continue;
3038
+ const countWords = (t) => t.split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length;
3039
+ let hiddenWords = 0, hiddenEnd = -1;
3040
+ for (let i = 0; i < doc.tags.length; i++) {
3041
+ const t = doc.tags[i];
3042
+ if (t.close || t.selfClosing || t.pos < hiddenEnd) continue;
3043
+ const cls = getAttr(t.attrs, 'class') || '';
3044
+ if (!(/(^|\s)(sr-only|visually-hidden|screen-reader-text|skip|skip-link)(\s|$)/i.test(cls) || /aria-hidden\s*=\s*["']?true/i.test(t.attrs) || /(^|\s)hidden(\s|=|$)/i.test(t.attrs) || /(^|\s)data-credits(\s|=|$)/i.test(t.attrs))) continue;
3045
+ const inner = innerOf(doc, i);
3046
+ if (!inner) continue;
3047
+ hiddenWords += countWords(plainText(inner.inner));
3048
+ hiddenEnd = inner.endPos;
3049
+ }
3050
+ const total = countWords(doc.text) - hiddenWords;
3051
+ if (total > 450) report(58, 'WARN', doc.file, 1, `${total} visible words on the page (450 at most)`, 'Cut copy: one idea per screen, sections become objects');
3052
+ }
3053
+ }
3054
+
3055
+ const SECOND_CTA = /^(learn more|see how it works|watch (the )?demo|get started|book a demo)$/i;
3056
+ function check42TwoButton(ctx) {
3057
+ for (const doc of ctx.docs) {
3058
+ const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
3059
+ if (h1 < 0) continue;
3060
+ const heroIdx = heroIndexOf(doc, h1);
3061
+ const hero = heroIdx >= 0 ? innerOf(doc, heroIdx) : null;
3062
+ if (!hero) continue;
3063
+ const anc = ancestorMap(doc);
3064
+ const controls = [];
3065
+ for (let i = heroIdx + 1; i < doc.tags.length && doc.tags[i].pos < hero.endPos; i++) {
3066
+ const t = doc.tags[i];
3067
+ if (t.close || t.selfClosing || !(t.name === 'button' || t.name === 'a')) continue;
3068
+ const cls = getAttr(t.attrs, 'class') || '';
3069
+ if (t.name === 'a' && !/btn|cta|button/i.test(cls)) continue;
3070
+ if ((anc.get(i) || []).includes('nav')) continue;
3071
+ controls.push({ t, cls, label: plainText(innerOf(doc, i)?.inner || '').trim() });
3072
+ }
3073
+ if (controls.length < 2) continue;
3074
+ const fillOf = (c) => { const decls = styleFor(ctx, c.cls, c.t.name, true); let raw = null; for (const d of decls) if (d.prop === 'background-color' || d.prop === 'background') raw = d.value; if (!raw || /--color-paper\b/.test(raw)) return false; const bg = resolveVars(raw, ctx.tokens); return !/^(none|transparent|inherit|initial|unset)$/i.test(bg.trim()) && !/transparent/i.test(bg) && !/var\(--color-paper/.test(raw); };
3075
+ const firstFilled = fillOf(controls[0]);
3076
+ if (firstFilled && fillOf(controls[1])) { report('F4', 'WARN', doc.file, controls[1].t.line, `two filled buttons side by side in the hero: "${trunc(controls[0].label, 20)}" and "${trunc(controls[1].label, 20)}"`, 'One filled primary per screen; the second action is outlined or a typographic link'); continue; }
3077
+ if (!SECOND_CTA.test(controls[1].label) || !firstFilled) continue;
3078
+ report(19, 'WARN', doc.file, controls[1].t.line, `the stock second action in the hero: "${trunc(controls[0].label, 20)}" then "${controls[1].label}"`, 'One action in the hero; the second button is the template speaking');
3079
+ }
3080
+ }
3081
+
3082
+ const PLACEHOLDER_IMG = /placehold\.co|via\.placeholder\.com|source\.unsplash\.com\/random|dummyimage\.com/i;
3083
+ function check68(ctx) {
3084
+ for (const doc of ctx.docs) {
3085
+ if (doc.kind !== 'html') continue;
3086
+ const slots = [];
3087
+ for (let i = 0; i < doc.tags.length; i++) {
3088
+ const t = doc.tags[i];
3089
+ if (t.close || !/\bdata-slot\s*=/.test(t.attrs)) continue;
3090
+ const inner = t.selfClosing ? null : innerOf(doc, i);
3091
+ if (inner && /<img\b[^>]*\bsrc\s*=\s*["'][^"']+["']/i.test(inner.inner)) continue;
3092
+ if (inner && (inner.inner.match(/<[a-z][\w-]*\b[^>]*>\s*[^<\s]/gi) || []).length >= 6) continue;
3093
+ let hiddenUp = false;
3094
+ for (let k = i - 1, d = 0; k >= 0 && !hiddenUp; k--) { const u = doc.tags[k]; if (u.selfClosing) continue; if (u.close) { d++; continue; } if (d > 0) { d--; continue; } if (/(?:^|\s)hidden(?:\s|=|$)/.test(u.attrs)) hiddenUp = true; }
3095
+ if (hiddenUp) continue;
3096
+ slots.push({ i, t });
3097
+ }
3098
+ if (slots.length && !ctx.docs.some((d) => d.kind !== 'html') && !/hallmarkSlots/.test(doc.src) && !ctx.docs.some((d) => d !== doc && /hallmarkSlots/.test(d.src))) report('F18', 'WARN', doc.file, slots[0].t.line, `${slots.length} slot${slots.length > 1 ? 's' : ''} and no slot script`, 'Paste the hallmarkSlots script the scaffold printed before </body>, so the user\'s images drop in (imagery.md § 3)');
3099
+ if (slots.length > 5) report(68, 'FAIL', doc.file, slots[5].t.line, `${slots.length} empty slots on one page`, 'Two to four slots carry a page; set the rest in type, names and facts in a list or table');
3100
+ const byRun = new Map();
3101
+ for (const sl of slots) {
3102
+ let depth = 0, parentIdx = -1;
3103
+ for (let k = sl.i - 1; k >= 0; k--) { const t = doc.tags[k]; if (t.selfClosing) continue; if (t.close) depth++; else if (depth === 0) { parentIdx = k; break; } else depth--; }
3104
+ const up = (from) => { let d = 0; for (let k = from - 1; k >= 0; k--) { const t = doc.tags[k]; if (t.selfClosing) continue; if (t.close) d++; else if (d === 0) return k; else d--; } return -1; };
3105
+ const gp = parentIdx >= 0 ? up(parentIdx) : -1;
3106
+ const ggp = gp >= 0 ? up(gp) : -1;
3107
+ for (const key of new Set([parentIdx, gp, ggp])) { if (key < 0) continue; byRun.set(key, (byRun.get(key) || 0) + 1); }
3108
+ }
3109
+ for (const [key, n] of byRun) if (n >= 4) { report(68, 'FAIL', doc.file, doc.tags[key].line, `${n} empty slots in one row or grid`, 'A set the page has no pictures of is set in type with one slot at most'); break; }
3110
+ }
3111
+ }
3112
+
3113
+ const FACT69 = /(?:[£$€¥]\s?\d[\d,.]*|\d[\d,.]*\s?(?:pm|am|MAD|kg|g|ml|km|mi|miles|%)\b|\b\d{1,2}:\d{2}\b)/gi;
3114
+ const REGION69 = /^(section|header|footer|article|aside|nav|form|dialog)$/;
3115
+ function check69(ctx) {
3116
+ for (const doc of ctx.docs) {
3117
+ if (doc.kind !== 'html') continue;
3118
+ const bodyAt = doc.tags.findIndex((t) => !t.close && t.name === 'body');
3119
+ const stack = [];
3120
+ const seen = new Map();
3121
+ let uid = 0;
3122
+ const regionOf = () => {
3123
+ const r = stack.find((e) => REGION69.test(e.name));
3124
+ if (r) return r.id;
3125
+ const band = stack.find((e, i) => i > 0 && (stack[i - 1].name === 'body' || stack[i - 1].name === 'main') && e.name !== 'main');
3126
+ return band ? band.id : 'root';
3127
+ };
3128
+ for (let i = Math.max(bodyAt, 0); i < doc.tags.length; i++) {
3129
+ const t = doc.tags[i];
3130
+ if (t.close) { const at = stack.map((e) => e.name).lastIndexOf(t.name); if (at >= 0) stack.length = at; }
3131
+ else if (!t.selfClosing) stack.push({ name: t.name, id: uid++ });
3132
+ const next = doc.tags[i + 1];
3133
+ if (/^(script|style|template|noscript)$/.test(t.name) && !t.close) continue;
3134
+ const chunk = doc.masked.slice(t.end, next ? next.pos : doc.masked.length)
3135
+ .replace(/&pound;/g, '£').replace(/&euro;/g, '€').replace(/&nbsp;|&#160;/g, ' ');
3136
+ if (!chunk.trim()) continue;
3137
+ const region = regionOf();
3138
+ for (const f of chunk.match(FACT69) || []) {
3139
+ const k = f.replace(/\s+/g, '').toLowerCase();
3140
+ if (!seen.has(k)) seen.set(k, new Set());
3141
+ seen.get(k).add(region);
3142
+ }
3143
+ }
3144
+ const loud = [...seen].map(([k, s]) => [k, s.size]).filter(([, n]) => n >= 4).sort((a, b) => b[1] - a[1]);
3145
+ if (loud.length) report(69, 'WARN', doc.file, 1, `the same fact printed in ${loud[0][1]} sections: ${loud.slice(0, 3).map(([k, n]) => `${k} (${n})`).join(', ')}`, 'Say each fact once, where it does work; cut the restatements');
3146
+ }
3147
+ }
3148
+
3149
+ function check70(ctx) {
3150
+ const SUCCESS = /\b(sent|thanks|thank you|reserved|booked|on the list|subscribed|in touch|received|confirmed|ordered|chosen|on its way|signed up|all set|you're in|you are in|done)\b/i;
3151
+ const NEG = /\b(not|nothing|no|never|isn't|wasn't|didn't|cannot|can't|failed|try again|soon)\b/i;
3152
+ for (const doc of ctx.docs) {
3153
+ const js = (doc.scripts && typeof doc.scripts === 'string') ? doc.scripts : (doc.src.match(/<script\b[^>]*>([\s\S]*?)<\/script>/gi) || []).join('\n');
3154
+ if (!/<form\b|type\s*=\s*["']submit/i.test(doc.src) || !js) continue;
3155
+ let m = null;
3156
+ for (const x of js.matchAll(/(["'`])((?:(?!\1)[^\\\n]|\\.){4,140})\1/g)) {
3157
+ const str = x[2];
3158
+ const head = str.trim().split(/\s+/).slice(0, 4).join(' ');
3159
+ if (str.trim().split(/\s+/).length >= 2 && SUCCESS.test(head) && !NEG.test(str)) { m = [x[0]]; break; }
3160
+ }
3161
+ if (!m) continue;
3162
+ if (/\bfetch\s*\(|XMLHttpRequest|navigator\.sendBeacon|\.submit\s*\(\s*\)|axios|\$\.ajax/.test(js)) continue;
3163
+ report(70, 'WARN', doc.file, doc.lineOf ? doc.lineOf(doc.src.indexOf(m[0]) >= 0 ? doc.src.indexOf(m[0]) : 0) : 1, `a script prints success ("${trunc(m[0].replace(/["'`]/g, ''), 30)}") with no request behind it`, 'No success state until the form posts somewhere; an empty data-endpoint and a handoff note');
3164
+ }
3165
+ }
3166
+
3167
+ function check47Placeholders(ctx) {
3168
+ for (const doc of ctx.docs) {
3169
+ const m = PLACEHOLDER_IMG.exec(doc.src);
3170
+ if (m) report('47c', 'WARN', doc.file, doc.lineOf(m.index), `placeholder image service ${m[0]}`, 'A labelled data-slot in the theme colours, or a real asset');
3171
+ }
3172
+ for (const c of ctx.cssFiles) {
3173
+ const m = PLACEHOLDER_IMG.exec(c.raw);
3174
+ if (m) report('47c', 'WARN', c.file, c.raw.slice(0, m.index).split('\n').length, `placeholder image service ${m[0]}`, 'A labelled data-slot in the theme colours, or a real asset');
3175
+ }
3176
+ }
3177
+
3178
+ const TAILWIND_HEX = /#(0f172a|1e293b|6366f1|8b5cf6|3b82f6|f8fafc|64748b)\b/i;
3179
+ function check65Tailwind(ctx) {
3180
+ for (const r of ctx.rules) {
3181
+ if (!isTokenRule(r) || r.selector === '@font-face') continue;
3182
+ for (const d of r.decls) {
3183
+ const m = TAILWIND_HEX.exec(d.value);
3184
+ if (m) report(74, 'WARN', r.file, d.line, `default Tailwind palette ${m[0]} on ${d.prop}`, 'Mix the colour for this page in OKLCH; a stock swatch is the AI look');
3185
+ }
3186
+ }
3187
+ }
3188
+
3189
+ function splitTop(v) {
3190
+ const out = [];
3191
+ let depth = 0, cur = '';
3192
+ for (const ch of v) {
3193
+ if (ch === '(') depth++;
3194
+ if (ch === ')') depth--;
3195
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += ch;
3196
+ }
3197
+ if (cur.trim()) out.push(cur);
3198
+ return out;
3199
+ }
3200
+ const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)|color-mix\((?:[^()]|\([^()]*\))*\)|\b(white|black|transparent)\b/i;
3201
+ const lengthPx = (x) => (x === '0' ? 0 : pxOf(x));
3202
+ function check29Glow(ctx) {
3203
+ const seen = new Set();
3204
+ const once = (key, file, line, ev, fix) => { if (seen.has(key)) return; seen.add(key); report(29, 'WARN', file, line, ev, fix); };
3205
+ for (const r of mergedDeclsBySelector(ctx)) {
3206
+ if (isTokenRule(r)) continue;
3207
+ const local = {};
3208
+ for (const d of r.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;
3209
+ const bf = r.decls.find((d) => /^(-webkit-)?backdrop-filter$/.test(d.prop) && /blur\(/i.test(d.value));
3210
+ if (bf) {
3211
+ const bgD = r.decls.filter((d) => /^background(-color)?$/.test(d.prop)).pop();
3212
+ const rv = bgD ? resolveVars(bgD.value, ctx.tokens, local).trim() : '';
3213
+ const c = parseColor(rv);
3214
+ const mixed = /color-mix\([^)]*\b(white|black|--color-(paper|ink))\b[^)]*\btransparent\b/i.test(rv);
3215
+ const nearWhite = c && c.r > 0.85 && c.g > 0.85 && c.b > 0.85;
3216
+ const nearBlack = c && c.r < 0.15 && c.g < 0.15 && c.b < 0.15;
3217
+ if ((c && c.alpha < 0.95 && (nearWhite || nearBlack)) || (!c && mixed)) {
3218
+ once('glass:' + r.selector, r.file, bf.line, `glass panel: backdrop-filter blur over a translucent ${nearBlack ? 'black' : 'white'} on ${trunc(r.selector, 36)}`, 'Solid or grained surface; elevation by lightness');
3219
+ }
3220
+ }
3221
+ for (const d of r.decls) {
3222
+ if (d.prop !== 'box-shadow') continue;
3223
+ for (const part of splitTop(resolveVars(d.value, ctx.tokens, local))) {
3224
+ const cm = COLOR_RE.exec(part);
3225
+ if (!cm) continue;
3226
+ const c = parseColor(cm[0]);
3227
+ if (!c || c.alpha <= 0.05) continue;
3228
+ const ok = c.okl || srgbToOklch(c);
3229
+ if (ok.C <= 0.04) continue;
3230
+ const lens = part.replace(cm[0], ' ').replace(/\binset\b/, ' ').trim().split(/\s+/).filter(Boolean).map(lengthPx);
3231
+ if (lens.length >= 3 && lens[0] === 0 && lens[1] === 0 && lens[2] != null && lens[2] > 20) {
3232
+ once('glow:' + r.selector, r.file, d.line, `chromatic zero-offset glow (blur ${Math.round(lens[2])}px) on ${trunc(r.selector, 36)}`, 'Shadows carry offset and blur in a neutral; colour is not depth');
3233
+ break;
3234
+ }
3235
+ }
3236
+ }
3237
+ if (/::(before|after)/.test(r.selector) && r.decls.some((d) => d.prop === 'position' && /absolute|fixed/.test(d.value))) {
3238
+ const f = r.decls.find((d) => d.prop === 'filter' && /blur\(\s*(\d+(?:\.\d+)?)px/i.test(d.value));
3239
+ const n = f ? parseFloat(/blur\(\s*(\d+(?:\.\d+)?)px/i.exec(f.value)[1]) : 0;
3240
+ if (n > 40) once('orb:' + r.selector, r.file, f.line, `blurred orb: filter blur(${n}px) on the positioned ${trunc(r.selector, 36)}`, 'Delete the wash; a surface is paper or ground, not a light show');
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ function check11Lift(ctx) {
3246
+ const lifts = new Map();
3247
+ for (const r of mergedDeclsBySelector(ctx)) {
3248
+ if (!/:hover/.test(r.selector)) continue;
3249
+ const t = r.decls.find((d) => (d.prop === 'transform' && /translateY\(\s*-\s*\d/.test(resolveVars(d.value, ctx.tokens))) || (d.prop === 'translate' && /^0(px)?\s+-\d/.test(resolveVars(d.value, ctx.tokens))));
3250
+ const sh = r.decls.find((d) => d.prop === 'box-shadow');
3251
+ if (t && sh) for (const part of r.selector.split(',')) lifts.set(baseSelector(part), { r, t });
3252
+ }
3253
+ if (lifts.size < 3) return;
3254
+ const third = [...lifts.values()][2];
3255
+ report(11, 'WARN', third.r.file, third.t.line, `the hover lift (translateY plus shadow) on ${lifts.size} selectors (${trunc([...lifts.keys()].slice(0, 3).join(', '), 44)})`, 'One signal per element; the same lift on every card is the template');
3256
+ }
3257
+
3258
+ function check15Fade(ctx) {
3259
+ const focusProps = new Map();
3260
+ for (const r of ctx.rules) {
3261
+ if (!/:focus-visible/.test(r.selector)) continue;
3262
+ for (const part of r.selector.split(',')) {
3263
+ if (!/:focus-visible/.test(part)) continue;
3264
+ const base = baseSelector(part).replace(/^\*$/, '');
3265
+ const set = focusProps.get(base) || new Set();
3266
+ for (const d of r.decls) {
3267
+ if (d.prop === 'box-shadow') set.add('box-shadow');
3268
+ if (/^outline/.test(d.prop)) set.add('outline');
3269
+ }
3270
+ focusProps.set(base, set);
3271
+ }
3272
+ }
3273
+ if (!focusProps.size) return;
3274
+ const global = focusProps.get('') || new Set();
3275
+ for (const r of ctx.rules) {
3276
+ if (r.keyframes || /:focus|:hover|:active/.test(r.selector)) continue;
3277
+ for (const d of r.decls) {
3278
+ if (d.prop !== 'transition' && d.prop !== 'transition-property') continue;
3279
+ for (const prop of ['box-shadow', 'outline']) {
3280
+ if (!new RegExp(`(^|[\\s,])${prop}(-[a-z]+)?\\b`).test(d.value)) continue;
3281
+ const own = r.selector.split(',').some((p) => (focusProps.get(baseSelector(p)) || new Set()).has(prop));
3282
+ if (global.has(prop) || own) {
3283
+ report(15, 'FAIL', r.file, d.line, `${trunc(r.selector, 32)} transitions ${prop} while :focus-visible sets it (the ring fades in)`, 'Rings appear instantly: transition the hover property only, or draw the ring with a property that is not transitioned');
3284
+ break;
3285
+ }
3286
+ }
3287
+ }
3288
+ }
3289
+ }
3290
+
3291
+ function checkF7Pills(ctx) {
3292
+ const CONTROLISH = /btn|button|cta|input|select|textarea|switch|toggle|control|chip|pill|tag|badge|status/i;
3293
+ const sels = new Set();
3294
+ let where = null;
3295
+ for (const r of mergedDeclsBySelector(ctx)) {
3296
+ if (isTokenRule(r)) continue;
3297
+ const d = r.decls.filter((x) => x.prop === 'border-radius').pop();
3298
+ if (!d) continue;
3299
+ if (!/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue;
3300
+ for (const part of r.selector.split(',')) {
3301
+ const b = baseSelector(part);
3302
+ if (!b || CONTROLISH.test(b)) continue;
3303
+ sels.add(b);
3304
+ where = where || { r, d };
3305
+ }
3306
+ }
3307
+ if (sels.size >= 5) report('F7', 'WARN', where.r.file, where.d.line, `the pill radius on ${sels.size} non-control selectors (${trunc([...sels].slice(0, 4).join(', '), 48)})`, 'Pills are for controls; boxes and media take the card radius');
3308
+ }
3309
+
3310
+ const TW_SIZE = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128 };
3311
+ const TW_TRACK = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 };
3312
+ const TW_HUE = { red: 25, orange: 47, amber: 70, yellow: 86, lime: 131, green: 150, emerald: 163, teal: 183, cyan: 215, sky: 237, blue: 260, indigo: 277, violet: 293, purple: 304, fuchsia: 322, pink: 354, rose: 16 };
3313
+ const TW_COLOURS = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
3314
+ const TW_PAL = new RegExp(`^(?:bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|outline|decoration|divide|from|via|to|fill|stroke|shadow|accent|caret|placeholder|inset-shadow|inset-ring|drop-shadow)-(?:${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/(?:\\d{1,3}|\\[[^\\]]*\\]))?$`);
3315
+ const TW_SHADE = new RegExp(`^(${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/\\S*)?$`);
3316
+ const TW_ACCENT = /^text-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:500|600|700)(?:\/\S*)?$/;
3317
+ const TW_PURPLE = /^(?:purple|violet|fuchsia|indigo)$/;
3318
+ const TW_BLUE = /^(?:blue|sky|cyan|indigo)$/;
3319
+ const TW_PINK = /^(?:pink|rose)$/;
3320
+ const TW_GREY = /^(?:slate|gray|zinc|neutral|stone)$/;
3321
+ const TW_NONFILL = /^bg-(?:none|transparent|inherit|current|fixed|local|scroll|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[\w-]+)?|(?:left|right|top|bottom)-(?:top|bottom|left|right)|clip-[\w-]+|origin-[\w-]+|blend-[\w-]+|gradient-to-[\w-]+|(?:linear|radial|conic)(?:-.*)?|size-.*|position-.*|\[(?:url|length|position|size|image|(?:repeating-)?(?:linear|radial|conic)-gradient)[^\]]*\].*)$/;
3322
+ const TW_GLASS = /^bg-(?:white|black|(?:slate|gray|zinc|neutral|stone)-(?:50|100|900|950)|paper|ink|background|foreground)\/(\d{1,2}|\[(?:0?\.\d+|\d{1,2}%)\])$/;
3323
+ const TW_MONO = /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i;
3324
+ const TW_LOGO = /logo|brand|wordmark/i;
3325
+ const TW_ROOT = { size: null, em: null, upper: false, caps: false, noLabel: false, code: false, eyeSkip: false, inOl: false, logo: false, logoSelf: false };
3326
+ const TW_PARSED = new Map();
3327
+
3328
+ function twParse(tok) {
3329
+ let p = TW_PARSED.get(tok);
3330
+ if (p) return p;
3331
+ const variants = [];
3332
+ let depth = 0, cur = '';
3333
+ for (const ch of tok) {
3334
+ if (ch === '[' || ch === '(') depth++;
3335
+ else if ((ch === ']' || ch === ')') && depth > 0) depth--;
3336
+ if (ch === ':' && depth === 0) { variants.push(cur); cur = ''; } else cur += ch;
3337
+ }
3338
+ let base = cur;
3339
+ if (base.startsWith('!')) base = base.slice(1);
3340
+ if (base.endsWith('!')) base = base.slice(0, -1);
3341
+ const neg = base.length > 1 && base.startsWith('-');
3342
+ if (neg) base = base.slice(1);
3343
+ p = { variants: variants.map((v) => v.replace(/^(?:group|peer)-/, '').replace(/\/[\w-]+$/, '')), base, neg };
3344
+ TW_PARSED.set(tok, p);
3345
+ return p;
3346
+ }
3347
+
3348
+ const twTokens = (ctx) => ctx.twTok || ctx.tokens;
3349
+ const twValue = (tok, v) => resolveVars(v, tok).trim().replace(/^(-?)\./, '$10.');
3350
+
3351
+ function twSize(ctx, b) {
3352
+ const tok = twTokens(ctx);
3353
+ let m = /^text-(xs|sm|base|lg|[2-9]?xl)(?:\/\S+)?$/.exec(b);
3354
+ if (m) return TW_SIZE[m[1]];
3355
+ m = /^text-(?:\[(?:length:)?([^\]]+)\]|\((?:length:)?(--[\w-]+)\))(?:\/\S+)?$/.exec(b);
3356
+ let v = m ? (m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`) : null;
3357
+ if (!m) {
3358
+ m = /^text-([a-z][\w-]*?)(?:\/\S+)?$/.exec(b);
3359
+ v = m && tok[`--text-${m[1]}`];
3360
+ }
3361
+ if (!v) return null;
3362
+ const r = twValue(tok, v);
3363
+ const cl = /^clamp\(\s*([^,]+),/.exec(r);
3364
+ return pxOf(cl ? twValue(tok, cl[1]) : r);
3365
+ }
3366
+
3367
+ function twTrack(ctx, b, neg) {
3368
+ const tok = twTokens(ctx);
3369
+ let m = /^tracking-(tighter|tight|normal|wide|wider|widest)$/.exec(b);
3370
+ let v = null;
3371
+ if (m) v = `${TW_TRACK[m[1]]}em`;
3372
+ else if ((m = /^tracking-(?:\[([^\]]+)\]|\((--[\w-]+)\))$/.exec(b))) v = m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`;
3373
+ else if ((m = /^tracking-([a-z][\w-]*)$/.exec(b)) && tok[`--tracking-${m[1]}`]) v = tok[`--tracking-${m[1]}`];
3374
+ if (v == null) return null;
3375
+ const r = twValue(tok, v);
3376
+ const e = /^(-?\d*\.?\d+)em$/.exec(r);
3377
+ const px = e ? null : pxOf(r);
3378
+ if (!e && px == null) return null;
3379
+ const out = e ? parseFloat(e[1]) : px;
3380
+ return e ? { em: neg ? -out : out } : { px: neg ? -out : out };
3381
+ }
3382
+
3383
+ const twHue = (c) => {
3384
+ if (!c || c.alpha <= 0.05) return null;
3385
+ const ok = c.okl || srgbToOklch(c);
3386
+ return ok.C > 0.04 ? ok.H : null;
3387
+ };
3388
+ function twStop(ctx, v, tok) {
3389
+ if (/^\[?-?\d*\.?\d+%\]?$/.test(v)) return null;
3390
+ const tokens = twTokens(ctx);
3391
+ const pm = TW_SHADE.exec(v);
3392
+ if (pm) return { tok, kind: 'palette', pal: pm[1], hue: TW_GREY.test(pm[1]) ? null : TW_HUE[pm[1]] };
3393
+ if (/^(?:white|black|transparent|current|inherit)(?:\/\S*)?$/.test(v)) return { tok, kind: 'plain', hue: null };
3394
+ if (v.startsWith('(')) return { tok, kind: 'token', hue: twHue(parseColor(twValue(tokens, `var${v.replace(/\/\S*$/, '').replace(/^\((?:color:)?/, '(')}`))) };
3395
+ if (v.startsWith('[')) {
3396
+ const inner = v.slice(1, v.lastIndexOf(']')).replace(/_/g, ' ');
3397
+ return { tok, kind: /var\(/.test(inner) ? 'token' : 'literal', hue: twHue(parseColor(twValue(tokens, inner))) };
3398
+ }
3399
+ const tv = tokens[`--color-${v.replace(/\/\S*$/, '')}`];
3400
+ return { tok, kind: 'token', hue: tv ? twHue(parseColor(twValue(tokens, tv))) : null };
3401
+ }
3402
+
3403
+ function twFamily(v) {
3404
+ if (/var\(/.test(v)) return null;
3405
+ const named = v.replace(/^family-name:/, '');
3406
+ if (named === v && !/^['"]/.test(v) && /^(?:\d|weight:|number:)/.test(v)) return null;
3407
+ const fam = firstFamily(named.replace(/_/g, ' '));
3408
+ return !fam || GENERIC_FAMILIES.has(fam) ? null : fam;
3409
+ }
3410
+
3411
+ function twLiterals(b, tok, f) {
3412
+ for (const m of b.matchAll(/\[([^\]]+)\]/g)) {
3413
+ const v = m[1].replace(/url\([^)]*\)/gi, ' ').replace(/_/g, ' ');
3414
+ const fam = /^font-family:(.+)$/.exec(v);
3415
+ if (fam && !/var\(/.test(fam[1]) && !GENERIC_FAMILIES.has(firstFamily(fam[1]))) f.font = f.font || tok;
3416
+ for (const c of v.matchAll(TW_COLOR_LIT)) {
3417
+ const col = parseColor(c[0]);
3418
+ if (col && col.alpha > 0) f.colours.push({ tok, oklch: /^oklch/i.test(c[0]) });
3419
+ }
3420
+ }
3421
+ }
3422
+ const TW_COLOR_LIT = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
3423
+
3424
+ function twInfo(ctx, toks, tw) {
3425
+ const f = {
3426
+ upper: false, normalCase: false, caps: false, size: null, em: null, mono: false, accent: null,
3427
+ tAll: null, bounce: null, overshoot: null, hScale: null, hRotate: null, grad: false, stops: [], clip: false, clear: false,
3428
+ bblur: null, glass: null, blur: 0, blurTok: null, pos: null, fill: false, shadows: [], shadowCol: null, vh: null,
3429
+ oxHidden: null, oxClip: false, colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null,
3430
+ transform: false, transition: false, caret: false, scrollbar: false, hero: false, padded: false,
3431
+ };
3432
+ let sizeV = null;
3433
+ const tracks = [];
3434
+ for (const tok of toks) {
3435
+ if (tok.includes('__')) continue;
3436
+ const p = twParse(tok);
3437
+ const b = p.base;
3438
+ if (!b) continue;
3439
+ const plain = !p.variants.length;
3440
+ for (const v of p.variants) tw.variants.add(v);
3441
+ const hover = p.variants.includes('hover');
3442
+ if (TW_PAL.test(b)) f.pal.push(b.replace(/\/.*$/, ''));
3443
+ if (b.includes('[')) twLiterals(b, tok, f);
3444
+ if (/hero/i.test(b)) f.hero = true;
3445
+ if (b === 'uppercase') { f.upper = true; continue; }
3446
+ if (b === 'normal-case') { if (plain) f.normalCase = true; continue; }
3447
+ if (b === 'small-caps' || /^\[font-variant(?:-caps)?:[^\]]*(?:small|petite)-caps/.test(b) || /^\[font-feature-settings:[^\]]*(?:smcp|c2sc)/.test(b)) { f.caps = true; continue; }
3448
+ if (b.startsWith('text-')) {
3449
+ const px = twSize(ctx, b);
3450
+ if (px != null) {
3451
+ if (plain) f.size = px; else sizeV = sizeV == null ? px : Math.min(sizeV, px);
3452
+ continue;
3453
+ }
3454
+ if (b === 'text-transparent') { f.clear = true; continue; }
3455
+ if (TW_ACCENT.test(b)) f.accent = f.accent || tok;
3456
+ continue;
3457
+ }
3458
+ if (b.startsWith('tracking-')) {
3459
+ const r = twTrack(ctx, b, p.neg);
3460
+ if (r) tracks.push({ r, plain });
3461
+ continue;
3462
+ }
3463
+ if (b === 'font-mono') { f.mono = true; continue; }
3464
+ if (b.startsWith('font-')) {
3465
+ const arb = /^font-\[(.+)\]$/.exec(b);
3466
+ const fam = arb ? twFamily(arb[1]) : null;
3467
+ if (fam) { f.font = f.font || tok; if (TW_MONO.test(fam)) f.mono = true; }
3468
+ const named = !arb && /^font-([a-z][\w-]*)$/.exec(b);
3469
+ const tv = named && twTokens(ctx)[`--font-${named[1]}`];
3470
+ if (tv && /mono|courier|code/i.test(firstFamily(resolveVars(tv, twTokens(ctx))))) f.mono = true;
3471
+ continue;
3472
+ }
3473
+ if (b === 'transition-all' || /^transition-\[[^\]]*\b(?:all|width|height|top|left|right|bottom|inset)\b/.test(b) || /^\[transition(?:-property)?:[^\]]*\b(?:all|width|height|top|left)\b/.test(b)) {
3474
+ f.tAll = f.tAll || tok; f.transition = true;
3475
+ continue;
3476
+ }
3477
+ if (/^transition(?:-transform)?$/.test(b)) { f.transition = true; if (b !== 'transition') f.transform = true; continue; }
3478
+ if (b.startsWith('animate-')) {
3479
+ if (b !== 'animate-none') f.animate = f.animate || tok;
3480
+ if (b === 'animate-bounce') f.bounce = f.bounce || tok;
3481
+ continue;
3482
+ }
3483
+ const ez = /^ease-\[cubic-bezier\(([^)]*)\)\]$/.exec(b);
3484
+ if (ez) {
3485
+ const n = ez[1].split(/[,_\s]+/).filter(Boolean).map(Number);
3486
+ if (n.length === 4 && [n[1], n[3]].some((y) => y > 1.05 || y < -0.05)) f.overshoot = f.overshoot || tok;
3487
+ continue;
3488
+ }
3489
+ if (/^(?:scale|translate|rotate|skew)-/.test(b)) {
3490
+ f.transform = true;
3491
+ if (hover && /^scale-/.test(b) && !/^scale-(?:[xy]-)?100$/.test(b)) f.hScale = f.hScale || tok;
3492
+ if (hover && /^rotate-/.test(b) && b !== 'rotate-0') f.hRotate = f.hRotate || tok;
3493
+ continue;
3494
+ }
3495
+ if (/^bg-(?:gradient-to-|linear-|radial|conic)|^bg-\[(?:repeating-)?(?:linear|radial|conic)-gradient/.test(b) || /^\[background(?:-image)?:[^\]]*gradient\(/.test(b)) f.grad = true;
3496
+ const st = /^(?:from|via|to)-(.+)$/.exec(b);
3497
+ if (st) { const s = twStop(ctx, st[1], tok); if (s) f.stops.push(s); continue; }
3498
+ if (b === 'bg-clip-text' || /^\[(?:-webkit-)?background-clip:text\]$/.test(b)) { f.clip = true; continue; }
3499
+ if (b === '[-webkit-text-fill-color:transparent]') { f.clear = true; continue; }
3500
+ if (/^backdrop-blur(?:-|$)/.test(b) || /^\[(?:-webkit-)?backdrop-filter:[^\]]*blur/.test(b)) { f.bblur = f.bblur || tok; continue; }
3501
+ const bl = /^blur-(?:(2xl|3xl)|\[(\d*\.?\d+)(px|rem)\])$/.exec(b);
3502
+ if (bl) {
3503
+ const px = bl[1] ? (bl[1] === '2xl' ? 40 : 64) : parseFloat(bl[2]) * (bl[3] === 'rem' ? 16 : 1);
3504
+ if (px > f.blur) { f.blur = px; f.blurTok = tok; }
3505
+ continue;
3506
+ }
3507
+ if (b === 'absolute' || b === 'fixed') { f.pos = f.pos || b; continue; }
3508
+ if (b.startsWith('bg-')) {
3509
+ const gm = TW_GLASS.exec(b);
3510
+ if (gm) {
3511
+ const v = gm[1].replace(/^\[|\]$/g, '');
3512
+ const a = v.endsWith('%') ? parseFloat(v) / 100 : v.includes('.') ? parseFloat(v) : parseFloat(v) / 100;
3513
+ if (a < 0.95) f.glass = f.glass || tok;
3514
+ }
3515
+ if (!TW_NONFILL.test(b)) f.fill = true;
3516
+ continue;
3517
+ }
3518
+ if (b.startsWith('shadow-')) {
3519
+ const sm = /^shadow-\[(.+)\]$/.exec(b);
3520
+ if (sm) f.shadows.push({ v: sm[1], tok });
3521
+ else if (!/^shadow-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)$/.test(b)) f.shadowCol = f.shadowCol || b.slice(7);
3522
+ continue;
3523
+ }
3524
+ if (/^(?:min-)?h-(?:screen|\[[^\]]*\d\s*vh[^\]]*\])$/.test(b)) { f.vh = f.vh || tok; continue; }
3525
+ if (b === 'overflow-x-hidden') { f.oxHidden = f.oxHidden || tok; continue; }
3526
+ if (b === 'overflow-x-clip' || b === 'overflow-clip') { f.oxClip = true; continue; }
3527
+ if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; continue; }
3528
+ if (b === 'grid-cols-3') { f.cols3 = true; continue; }
3529
+ if (/^p[xytrblse]?-/.test(b)) { f.padded = true; continue; }
3530
+ if (b.startsWith('caret-')) { f.caret = true; continue; }
3531
+ if (b.startsWith('scrollbar') || b.startsWith('[scrollbar-')) f.scrollbar = true;
3532
+ }
3533
+ if (f.size == null) f.size = sizeV;
3534
+ let emV = null;
3535
+ for (const { r, plain } of tracks) {
3536
+ const em = r.em != null ? r.em : r.px / (f.size || 16);
3537
+ if (plain) f.em = em; else emV = emV == null ? em : Math.max(emV, em);
3538
+ }
3539
+ if (f.em == null) f.em = emV;
3540
+ return f;
3541
+ }
3542
+
3543
+ const TW_CLASS_ATTR = /(?:^|\s)(class|className|:class|v-bind:class|x-bind:class|class:list)\s*=\s*/g;
3544
+ function twInterp(val) {
3545
+ const parts = [];
3546
+ let i = 0, seg = '', after = false;
3547
+ while (i < val.length) {
3548
+ if (val[i] === '{') {
3549
+ parts.push(cutGlued(seg, after, true));
3550
+ const e = braceEnd(val, i);
3551
+ parts.push(clsLits(val.slice(i + 1, e)).join(' '));
3552
+ i = e + 1; seg = ''; after = true;
3553
+ continue;
3554
+ }
3555
+ seg += val[i++];
3556
+ }
3557
+ parts.push(cutGlued(seg, after, false));
3558
+ return parts.join(' ').split(/\s+/).filter(Boolean);
3559
+ }
3560
+ function twTokensOf(doc, t) {
3561
+ if (doc.classAt) return doc.classAt.get(t.pos) || [];
3562
+ const tag = (doc.raw || doc.src).slice(t.pos, t.end);
3563
+ if (!tag.includes('class')) return [];
3564
+ const out = [];
3565
+ for (const m of tag.matchAll(TW_CLASS_ATTR)) {
3566
+ const i = m.index + m[0].length;
3567
+ const q = tag[i];
3568
+ let val, dyn = m[1] !== 'class' && m[1] !== 'className';
3569
+ if (q === '"' || q === "'") { const e = tag.indexOf(q, i + 1); val = tag.slice(i + 1, e < 0 ? tag.length : e); }
3570
+ else if (q === '{') { val = tag.slice(i + 1, braceEnd(tag, i)); dyn = true; }
3571
+ else val = (/^[^\s>]*/.exec(tag.slice(i)) || [''])[0];
3572
+ if (dyn) out.push(...clsLits(val));
3573
+ else if (val.includes('{')) out.push(...twInterp(val));
3574
+ else out.push(...val.split(/\s+/));
3575
+ }
3576
+ for (const d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]);
3577
+ return out.filter(Boolean);
3578
+ }
3579
+
3580
+ function twFrame(parent, t, f, toks) {
3581
+ const logo = toks.some((x) => TW_LOGO.test(x)) || TW_LOGO.test(getAttr(t.attrs, 'aria-label') || '');
3582
+ return {
3583
+ size: f && f.size != null ? f.size : parent.size,
3584
+ em: f && f.em != null ? f.em : parent.em,
3585
+ upper: f ? f.upper || (!f.normalCase && parent.upper) : parent.upper,
3586
+ caps: (f && f.caps) || parent.caps,
3587
+ noLabel: parent.noLabel || /^(?:svg|pre|code|abbr|kbd|samp)$/.test(t.name),
3588
+ code: parent.code || /^(?:pre|code|kbd|samp)$/.test(t.name),
3589
+ eyeSkip: parent.eyeSkip || EYEBROW_ANCESTOR_SKIP.test(t.name),
3590
+ inOl: parent.inOl || t.name === 'ol',
3591
+ logo: parent.logo || (t.name === 'a' && logo),
3592
+ logoSelf: logo,
3593
+ };
3594
+ }
3595
+
3596
+ function twGroup(ctx) {
3597
+ if (ctx._tw) return ctx._tw;
3598
+ const tw = { items: [], variants: new Set(), caret: false, scrollbar: false, motion: null, upperDisplay: false };
3599
+ ctx._tw = tw;
3600
+ const add = (it) => {
3601
+ const f = it.info;
3602
+ tw.items.push(it);
3603
+ if (f.caret) tw.caret = true;
3604
+ if (f.scrollbar) tw.scrollbar = true;
3605
+ if (!tw.motion && (f.animate || (f.transition && f.transform))) tw.motion = it;
3606
+ if (/^h[12]$/.test(it.name) && f.upper) tw.upperDisplay = true;
3607
+ };
3608
+ for (const doc of ctx.docs) {
3609
+ const stack = [];
3610
+ for (let i = 0; i < doc.tags.length; i++) {
3611
+ const t = doc.tags[i];
3612
+ if (t.close) { stack.pop(); continue; }
3613
+ const parent = stack.length ? stack[stack.length - 1] : TW_ROOT;
3614
+ const toks = twTokensOf(doc, t);
3615
+ const info = toks.length ? twInfo(ctx, toks, tw) : null;
3616
+ const frame = twFrame(parent, t, info, toks);
3617
+ if (info) add({ doc, idx: i, name: t.name, attrs: t.attrs, line: t.line, toks, info, frame });
3618
+ if (!t.selfClosing) stack.push(frame);
3619
+ }
3620
+ for (let c = 0; c < doc.comps.length; c++) {
3621
+ const t = doc.comps[c];
3622
+ if (t.close) continue;
3623
+ const toks = twTokensOf(doc, t);
3624
+ if (!toks.length) continue;
3625
+ const info = twInfo(ctx, toks, tw);
3626
+ add({ doc, idx: -1, comp: c, name: t.name, attrs: t.attrs, line: t.line, toks, info, frame: twFrame(TW_ROOT, t, info, toks) });
3627
+ }
3628
+ for (const v of doc.virtuals || []) {
3629
+ const info = twInfo(ctx, v.toks, tw);
3630
+ add({ doc, idx: -1, virtual: true, name: v.name, attrs: '', line: doc.lineOf(v.pos), toks: v.toks, info, frame: twFrame(TW_ROOT, { name: v.name, attrs: '' }, info, v.toks) });
3631
+ }
3632
+ }
3633
+ return tw;
3634
+ }
3635
+
3636
+ function twWhere(it) {
3637
+ return it.virtual ? `${it.name}() class string` : `<${it.name} class="${trunc(it.toks.join(' '), 32)}">`;
3638
+ }
3639
+
3640
+ function compInner(doc, c) {
3641
+ const open = doc.comps[c];
3642
+ if (open.selfClosing) return '';
3643
+ let depth = 1;
3644
+ for (let i = c + 1; i < doc.comps.length; i++) {
3645
+ const t = doc.comps[i];
3646
+ if (t.name !== open.name || t.selfClosing) continue;
3647
+ if (!t.close) depth++;
3648
+ else if (--depth === 0) return doc.src.slice(open.end, t.pos);
3649
+ }
3650
+ return '';
3651
+ }
3652
+
3653
+ function twPlain(doc, html) {
3654
+ let s = html;
3655
+ if (doc.kind !== 'html' && doc.kind !== 'jsx') for (let k = 0; k < 3 && /\{[^{}]*\}/.test(s); k++) s = s.replace(/\{[^{}]*\}/g, ' ');
3656
+ return plainText(s);
3657
+ }
3658
+
3659
+ function twText(it) {
3660
+ if (it.text == null) {
3661
+ let inner = '';
3662
+ if (it.idx >= 0) inner = innerOf(it.doc, it.idx)?.inner || '';
3663
+ else if (it.comp != null) inner = compInner(it.doc, it.comp);
3664
+ it.text = twPlain(it.doc, inner);
3665
+ }
3666
+ return it.text;
3667
+ }
3668
+
3669
+ function closeIndex(doc, idx) {
3670
+ if (doc.tags[idx].selfClosing) return idx;
3671
+ let depth = 1;
3672
+ for (let i = idx + 1; i < doc.tags.length; i++) {
3673
+ const t = doc.tags[i];
3674
+ if (t.selfClosing) continue;
3675
+ if (!t.close) depth++;
3676
+ else if (--depth === 0) return i;
3677
+ }
3678
+ return doc.tags.length - 1;
3679
+ }
3680
+
3681
+ function twGradient(ctx, f) {
3682
+ if (!f.stops.length || (!f.grad && f.stops.length < 2)) return null;
3683
+ const names = f.stops.filter((s) => s.pal).map((s) => s.pal);
3684
+ let banned = null;
3685
+ for (const a of names) {
3686
+ if (!TW_PURPLE.test(a)) continue;
3687
+ if (names.some((x) => x !== a && TW_BLUE.test(x))) { banned = 'purple-to-blue'; break; }
3688
+ if (names.some((x) => x !== a && TW_PINK.test(x))) { banned = 'purple-to-pink'; break; }
3689
+ }
3690
+ if (!banned) banned = bannedGradient(f.stops.map((s) => s.hue).filter((h) => h != null), { tokens: twTokens(ctx) });
3691
+ return { banned, text: f.clip && f.clear, list: f.stops.map((s) => s.tok).join(' ') };
3692
+ }
3693
+
3694
+ function twGlow(ctx, f) {
3695
+ const tokens = twTokens(ctx);
3696
+ for (const s of f.shadows) {
3697
+ for (const part of splitTop(resolveVars(s.v.replace(/_/g, ' '), tokens))) {
3698
+ const cm = COLOR_RE.exec(part);
3699
+ const lens = (cm ? part.replace(cm[0], ' ') : part).replace(/\binset\b/, ' ').trim().split(/\s+/).filter(Boolean).map(lengthPx);
3700
+ if (lens.length < 3 || lens[0] !== 0 || lens[1] !== 0 || lens[2] == null || lens[2] < 8) continue;
3701
+ let chroma = 0;
3702
+ if (cm) {
3703
+ const c = parseColor(cm[0]);
3704
+ if (c && c.alpha > 0.05) chroma = (c.okl || srgbToOklch(c)).C;
3705
+ } else if (f.shadowCol) {
3706
+ const name = f.shadowCol.replace(/\/\S*$/, '');
3707
+ const pm = TW_SHADE.exec(name);
3708
+ const tv = tokens[`--color-${name}`];
3709
+ chroma = pm ? (TW_GREY.test(pm[1]) ? 0 : 0.2) : tv ? (twHue(parseColor(resolveVars(tv, tokens).trim())) != null ? 0.2 : 0) : 0;
3710
+ }
3711
+ if (chroma > 0.04) return `glow: ${s.tok}${cm ? '' : ` with shadow-${f.shadowCol}`}`;
3712
+ }
3713
+ }
3714
+ return null;
3715
+ }
3716
+
3717
+ function twDataText(s) {
3718
+ const t = s.trim();
3719
+ const letters = (t.match(/[A-Za-z]/g) || []).length;
3720
+ const digits = (t.match(/\d/g) || []).length;
3721
+ if (digits >= letters) return true;
3722
+ if (/^v?\d+(?:\.\d+)+/.test(t) || /[⌘⌥⇧⌃⎋↵]|^(?:ctrl|cmd|alt|shift|option)\s*[+-]/i.test(t)) return true;
3723
+ if (/[{}();=<>]|^\$\s|^(?:npm|npx|pnpm|yarn|bun|git|curl|cd|ls|brew|pip|docker|node) |^--?[a-z]|[\w-]+\.[a-z][a-z0-9]{0,3}$|^[#.]?[a-z]+(?:[-_][a-z0-9]+)+$|^[a-z]+[A-Z]\w*$|^\/[\w/.-]*$|^https?:/.test(t)) return true;
3724
+ return digits > 0 && /^(?:\d{1,2}:\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d)/i.test(t);
3725
+ }
3726
+
3727
+ function tw67(it, tw) {
3728
+ const f = it.info, fr = it.frame;
3729
+ if (!(f.upper || f.caps || (f.em != null && f.em > 0.03))) return null;
3730
+ if (fr.logo || fr.logoSelf || fr.noLabel) return null;
3731
+ const size = fr.size, em = fr.em;
3732
+ if ((size != null && size >= 24) || (size == null && /^h[1-6]$/.test(it.name))) return null;
3733
+ const caps = fr.upper || fr.caps;
3734
+ const small = size != null && size <= 15;
3735
+ const tracked = em != null && em > 0.03;
3736
+ const control = /^(?:button|input)$/.test(it.name) || /(?:button|btn|cta)$/i.test(it.name) || (it.name === 'a' && f.fill && f.padded) || /role\s*=\s*["']?button/i.test(it.attrs);
3737
+ if (tw.upperDisplay && control && (em == null || em <= 0.04)) return null;
3738
+ if (!((caps && (small || tracked)) || (tracked && small))) return null;
3739
+ return { what: fr.caps ? 'small caps' : fr.upper ? 'uppercase' : 'letter-spaced', size, em: tracked ? Math.round(em * 1000) / 1000 : null };
3740
+ }
3741
+
3742
+ function tw54(it) {
3743
+ const f = it.info, fr = it.frame, doc = it.doc;
3744
+ if (it.idx < 0 || !/^(?:p|span|div|small)$/.test(it.name) || fr.eyeSkip) return null;
3745
+ if (!(f.upper || (f.em != null && f.em > 0.03) || f.accent)) return null;
3746
+ if (fr.size == null || fr.size > 14) return null;
3747
+ const t = doc.tags[it.idx];
3748
+ if (/role\s*=\s*["']?status|aria-live|aria-current|aria-hidden\s*=\s*["']?true/i.test(t.attrs)) return null;
3749
+ const c = closeIndex(doc, it.idx);
3750
+ const nx = doc.tags[c + 1];
3751
+ if (!nx || nx.close || !/^h[1-3]$/.test(nx.name)) return null;
3752
+ const inner = c > it.idx ? doc.src.slice(t.end, doc.tags[c].pos) : '';
3753
+ if (EYEBROW_BLOCKY.test(inner) || /<br\b|<time\b/i.test(inner)) return null;
3754
+ const href = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["']/i.exec(inner);
3755
+ if (href && !/^#?$/.test(href[1].trim())) return null;
3756
+ const text = twPlain(doc, inner);
3757
+ const signal =[f.upper ? 'uppercase' : null, f.em != null && f.em > 0.03 ? `tracked ${Math.round(f.em * 1000) / 1000}em` : null, f.accent].filter(Boolean).join(', ');
3758
+ if (!text) return doc.kind !== 'html' && c > it.idx && (doc.raw || '').slice(t.end, doc.tags[c].pos).includes('{') ? { text: '', next: nx.name, signal } : null;
3759
+ const words = text.split(/[\s·/|\u2014\u2013]+/).filter(Boolean);
3760
+ if (words.length > 7 || text.length > 56 || /[\w/-]+\.[a-z]{1,4}$/i.test(text)) return null;
3761
+ const stripped = text.replace(/^\s*(?:no\.?\s*)?\d{1,3}(?:\s*[·./\u2014\u2013-]\s*|\s+|$)/i, '').trim();
3762
+ if ((stripped.match(/\d/g) || []).length >= 2) return null;
3763
+ if (fr.inOl && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null;
3764
+ return { text, next: nx.name, signal };
3765
+ }
3766
+
3767
+ function twTiles(it, byIdx) {
3768
+ const doc = it.doc;
3769
+ let tiles = 0;
3770
+ for (const k of directChildren(doc, it.idx)) {
3771
+ if (!/<h3\b/i.test(k.html)) continue;
3772
+ const end = closeIndex(doc, k.idx);
3773
+ for (let j = k.idx + 1; j < end; j++) {
3774
+ const t = doc.tags[j];
3775
+ if (t.close) continue;
3776
+ if (/^h[1-6]$/.test(t.name)) break;
3777
+ const ti = byIdx.get(j);
3778
+ if (!ti || !ti.info.rounded || !ti.info.fill) continue;
3779
+ const inner = innerOf(doc, j);
3780
+ if (inner && /<svg\b|<[A-Z][\w.]*Icon\b|<Icon\b|<i\b[^>]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; }
3781
+ }
3782
+ }
3783
+ return tiles;
3784
+ }
3785
+
3786
+ function checkTailwind(ctx) {
3787
+ const tw = twGroup(ctx);
3788
+ if (!tw.items.length) return;
3789
+ const had = (gate, doc, line) => findings.some((x) => x.gate === gate && x.file === rel(doc.file) && x.line === line);
3790
+ const monoOk = monoIsMaterial({ tokens: twTokens(ctx) });
3791
+ const byDoc = new Map();
3792
+ for (const it of tw.items) {
3793
+ if (!byDoc.has(it.doc)) byDoc.set(it.doc, []);
3794
+ byDoc.get(it.doc).push(it);
3795
+ const { doc, info: f } = it;
3796
+ const at = (gate, grade, ev, fix) => report(gate, grade, doc.file, it.line, ev, fix);
3797
+ const where = twWhere(it);
3798
+ if (f.tAll) at('10a', 'FAIL', `${f.tAll} on ${where}`, 'Name the properties: transition-colors, transition-opacity or transition-transform');
3799
+ if (f.bounce || f.overshoot) at(12, 'FAIL', `${f.bounce ? 'bounce' : 'overshoot easing'} ${f.bounce || f.overshoot} on ${where}`, 'Exponential ease-out on UI state; no bounce or overshoot');
3800
+ const g = twGradient(ctx, f);
3801
+ if (g && g.banned) at('2a', 'FAIL', `${g.banned} gradient${g.text ? ' text' : ''} (${g.list}) on ${where}`, 'One anchor hue, solid ink');
3802
+ else if (g && g.text) at('2b', 'FAIL', `gradient text (${g.list}) on ${where}`, 'Solid ink; weight or a display face. Guard: a single-hue ramp from two tokens with 40 and 41 passing');
3803
+ const g29 = f.bblur && f.glass ? `glass: ${f.bblur} over ${f.glass}`
3804
+ : f.blur >= 40 && f.pos && (f.fill || f.grad || f.stops.length) ? `blurred orb: ${f.blurTok} on a filled ${f.pos} element`
3805
+ : twGlow(ctx, f);
3806
+ if (g29) at(29, 'WARN', `${g29} on ${where}`, 'Solid or grained surface; elevation by lightness, shadows with offset and blur');
3807
+ if (f.colours.length || f.font) {
3808
+ const what = [f.colours.length ? `colour literal ${f.colours[0].tok}` : null, f.font ? `font-family literal ${f.font}` : null].filter(Boolean).join(' and ');
3809
+ at(48, f.font || f.colours.some((c) => !c.oklch) ? 'FAIL' : 'WARN', `${what} in a class on ${where}`, 'Lift the value into the token block (@theme or :root) and use its utility name');
3810
+ }
3811
+ if ((f.hScale || f.hRotate) && /^(?:img|picture|image)$/i.test(it.name)) at(11, 'WARN', `${f.hScale || f.hRotate} on ${where}: the image moves on hover`, 'Imagery sits still; give the container the feedback');
3812
+ const t67 = tw67(it, tw);
3813
+ if (t67 && !had('67', doc, it.line)) {
3814
+ const text = it.virtual ? '' : twText(it);
3815
+ at(67, 'FAIL', `${text ? `"${trunc(text, 24)}" ` : ''}${t67.what}${t67.size != null ? ` at ${t67.size}px` : ''}${t67.em != null ? ` tracked ${t67.em}em` : ''} on ${where}`, 'Sentence case in the body face, a step smaller, untracked: drop uppercase and the tracking-* class');
3816
+ }
3817
+ const e54 = tw54(it);
3818
+ if (e54 && !had('54', doc, it.line)) {
3819
+ at(54, 'FAIL', `eyebrow ${e54.text ? `"${trunc(e54.text, 24)}" ` : ''}before <${e54.next}> (${e54.signal} at ${it.frame.size}px)`, 'Cut it; open the section another way (craft.md, Opening a section)');
3820
+ }
3821
+ if (!monoOk && f.mono && !it.virtual && !it.frame.code && !/^(?:code|pre|kbd|samp|time|var)$/.test(it.name) && it.frame.size != null && it.frame.size <= 14) {
3822
+ const text = twText(it);
3823
+ if (/[A-Za-z]/.test(text) && !twDataText(text)) at(64, 'FAIL', `font-mono on "${trunc(text, 24)}" at ${it.frame.size}px (${where})`, 'Labels in the body face at a smaller size and normal case; font-mono only for code and figures');
3824
+ }
3825
+ }
3826
+ for (const [doc, list] of byDoc) {
3827
+ const pal = new Map();
3828
+ let palAt = null;
3829
+ for (const it of list) for (const p of it.info.pal) if (!pal.has(p)) { pal.set(p, it); if (pal.size === 5) palAt = it; }
3830
+ if (palAt) report(74, 'WARN', doc.file, palAt.line, `default palette instead of theme tokens: ${pal.size} distinct palette classes (${[...pal.keys()].slice(0, 4).join(' ')})`, 'Extend the Tailwind theme with the Hallmark tokens and use those names');
3831
+ const scaled = list.filter((it) => it.info.hScale);
3832
+ if (scaled.length >= 3) report(11, 'WARN', doc.file, scaled[2].line, `hover scale on ${scaled.length} elements in this file (${scaled.slice(0, 3).map((it) => it.info.hScale).join(', ')})`, 'One hover signal per element; not every card grows');
3833
+ const first = new Set(['section', 'header'].map((n) => doc.tags.findIndex((t) => !t.close && t.name === n)).filter((x) => x >= 0));
3834
+ const byIdx = new Map();
3835
+ for (const it of list) if (it.idx >= 0) byIdx.set(it.idx, it);
3836
+ for (const it of list) {
3837
+ if (it.info.vh && !it.virtual && (first.has(it.idx) || it.info.hero)) report('6a', 'FAIL', doc.file, it.line, `hero sized in vh: ${it.info.vh} on ${twWhere(it)}`, 'min-h-dvh or min-h-svh');
3838
+ if (it.info.cols3 && it.idx >= 0) {
3839
+ const n = twTiles(it, byIdx);
3840
+ if (n >= 3) report(3, 'WARN', doc.file, it.line, `icon-tile row: ${n} children of ${twWhere(it)} open on an icon in a rounded filled tile above an h3`, 'Vary column widths or drop the icon tiles');
3841
+ }
3842
+ }
3843
+ }
3844
+ }
3845
+
3846
+ const EMOJI_ICON = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
3847
+ function checkComponentText(ctx) {
3848
+ for (const doc of ctx.docs) {
3849
+ for (let c = 0; c < doc.comps.length; c++) {
3850
+ const t = doc.comps[c];
3851
+ if (t.close || t.selfClosing) continue;
3852
+ const inner = compInner(doc, c);
3853
+ if (!inner || /<(?:p|div|li|section|article|ul|ol|table)\b/i.test(inner)) continue;
3854
+ const text = twPlain(doc, inner);
3855
+ if (!text) continue;
3856
+ if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) report(63, 'FAIL', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, 'A verb as the label; the link already says it goes somewhere');
3857
+ if ((text.match(/\s[·•]\s/g) || []).length >= 2) report(63, 'FAIL', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, 'Line breaks, columns or hairlines; one dot in a byline at most');
3858
+ if (text.length <= 4 && EMOJI_ICON.test(text)) report('30a', 'FAIL', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, 'One icon library or custom SVG');
3859
+ }
3860
+ }
3861
+ }
3862
+
3863
+ function checkF21(ctx) {
3864
+ for (const doc of ctx.docs) {
3865
+ if (doc.kind !== 'html') continue;
3866
+ let prev = 0;
3867
+ for (const m of doc.src.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)) {
3868
+ const level = Number(m[1]);
3869
+ if (prev && level > prev + 1) {
3870
+ report('F21', 'WARN', doc.file, doc.lineOf(m.index), `h${prev} is followed by h${level} ("${trunc(m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), 32)}")`, 'Step heading levels one at a time; size a head with CSS, never with its level');
3871
+ break;
3872
+ }
3873
+ prev = level;
3874
+ }
3875
+ }
3876
+ }
3877
+
3878
+ function checkF22(ctx) {
3879
+ for (const r of ctx.rules) {
3880
+ const j = r.decls.find((d) => d.prop === 'text-align' && /^justify\b/.test(d.value.trim()));
3881
+ if (j && !r.decls.some((d) => d.prop === 'hyphens' && /auto/.test(d.value))) { report('F22', 'WARN', r.file, j.line, `justified text without hyphenation on ${r.selector}`, 'Set it left, or add hyphens: auto'); return; }
3882
+ }
3883
+ for (const doc of ctx.docs) {
3884
+ const m = /class(?:Name)?\s*=\s*["'][^"']*(?<![\w-])text-justify(?![\w-])/.exec(doc.src);
3885
+ if (m && !/(?<![\w-])hyphens-auto(?![\w-])/.test(doc.src)) { report('F22', 'WARN', doc.file, doc.lineOf(m.index), 'justified text without hyphenation (text-justify)', 'Set it left, or add hyphens-auto'); return; }
3886
+ }
3887
+ }
3888
+
3889
+ function checkF23(ctx) {
3890
+ for (const doc of ctx.docs) {
3891
+ if (doc.kind !== 'html' || !/<html\b/i.test(doc.src)) continue;
3892
+ const missing = [];
3893
+ const html = /<html\b([^>]*)>/i.exec(doc.src);
3894
+ if (!html || !getAttr(html[1], 'lang')) missing.push('lang on <html>');
3895
+ if (!/<title\b[^>]*>\s*[^<\s][^<]*<\/title>/i.test(doc.src)) missing.push('a <title>');
3896
+ if (!/<meta\b[^>]*\bname\s*=\s*["']description["'][^>]*>/i.test(doc.src)) missing.push('a meta description');
3897
+ if (missing.length) report('F23', 'WARN', doc.file, 1, `the document lacks ${missing.join(', ')}`, 'Name the language, title the page in its own words, and describe it in one sentence for link previews');
3898
+ }
3899
+ }
3900
+
3901
+ function checkF24(ctx) {
3902
+ const wide = (value) => resolveVars(value, ctx.tokens).split(/,(?![^(]*\))/).some((layer) => {
3903
+ if (/\binset\b/.test(layer)) return false;
3904
+ const len = [...layer.replace(/(?:oklch|oklab|rgba?|hsla?|color|color-mix)\([^)]*\)/g, '').matchAll(/(-?\d*\.?\d+)(px|rem|em)?(?=\s|$)/g)].map((m) => parseFloat(m[1]) * (m[2] === 'rem' || m[2] === 'em' ? 16 : 1));
3905
+ return len.length >= 3 && len[2] + Math.min(0, len[3] || 0) >= 24;
3906
+ });
3907
+ for (const r of ctx.rules) {
3908
+ const sh = r.decls.find((d) => d.prop === 'box-shadow' && d.value.trim() !== 'none');
3909
+ if (!sh || !wide(sh.value)) continue;
3910
+ const edge = r.decls.find((d) => /^border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?$/.test(d.prop) && /(?:^|\s)(?:1px|0\.5px|thin|var\(--rule-hair\))(?:\s|$)/.test(d.value) && !/\b(?:none|transparent)\b/.test(d.value));
3911
+ if (edge) { report('F24', 'WARN', r.file, sh.line, `a hairline border under a wide soft shadow on ${r.selector}`, 'Commit to one: a defined edge, or elevation by shadow'); return; }
3912
+ }
3913
+ for (const doc of ctx.docs) {
3914
+ const m = /class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])border(?![\w-])[^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])|class(?:Name)?\s*=\s*["'][^"']*(?<![\w:-])shadow-(?:xl|2xl)(?![\w-])[^"']*(?<![\w:-])border(?![\w-])/.exec(doc.raw || doc.src);
3915
+ if (m) { report('F24', 'WARN', doc.file, doc.lineOf ? doc.lineOf(m.index) : 1, 'a hairline border under a wide soft shadow (border with shadow-xl or shadow-2xl)', 'Commit to one: a defined edge, or elevation by shadow'); return; }
3916
+ }
3917
+ }
3918
+
3919
+ let LIBRARY = null;
3920
+ function library() {
3921
+ if (LIBRARY) return LIBRARY;
3922
+ LIBRARY = new Map();
3923
+ try { for (const p of JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'photos.json'), 'utf8')).photos) { LIBRARY.set(p.file.split('/').pop(), p); LIBRARY.set(savedName(p.id), p); } } catch { /* no library */ }
3924
+ return LIBRARY;
3925
+ }
3926
+ // theme.mjs photos --save names a file after its title; match that name back to the library entry.
3927
+ const savedName = (id) => id.replace(/\.[a-z]+$/i, '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || `photo-${createHash('sha1').update(id).digest('hex').slice(0, 10)}`;
3928
+ const SAVED = /([a-z0-9-]+)-(?:960|1280|1920)\.jpg/g;
3929
+ const COMMONS = /upload\.wikimedia\.org\/wikipedia\/commons\/(?:thumb\/)?[0-9a-f]\/[0-9a-f]{2}\/([^/"'\s)?]+)/g;
3930
+ const STOCK = /(?:images|plus)\.unsplash\.com\/(premium_photo|photo)-[\w-]+|images\.pexels\.com\/photos\/\d+|cdn\.pixabay\.com\/photo\//g;
3931
+ function check76(ctx) {
3932
+ const lib = library();
3933
+ const text = ctx.docs.filter((d) => d.kind === 'html').map((d) => d.src).join('\n');
3934
+ const credited = (p) => /^(CC0|Public domain|PD)/i.test(p.license) || text.includes(p.page) || text.includes(p.page.replace(/^https:/, '')) || text.includes(p.author);
3935
+ const seen = new Set();
3936
+ for (const doc of ctx.docs) {
3937
+ let stock = 0, plus = 0;
3938
+ for (const m of doc.src.matchAll(STOCK)) {
3939
+ if (m[1] === 'premium_photo') { if (!plus++) report('76', 'FAIL', doc.file, doc.lineOf(m.index), 'an Unsplash+ photo, which needs a paid licence', 'Use a library photograph (theme.mjs photos) or the user\'s own'); }
3940
+ else if (!stock++) report('76', 'WARN', doc.file, doc.lineOf(m.index), 'a photo hotlinked from a stock site, whose terms bar images picked by an AI tool', 'Use a library photograph (theme.mjs photos) or the user\'s own; dismiss only if the user supplied this one');
3941
+ }
3942
+ for (const m of doc.src.matchAll(COMMONS)) {
3943
+ const name = m[1].replace(/^\d+px-/, '');
3944
+ if (seen.has(name)) continue;
3945
+ seen.add(name);
3946
+ const p = lib.get(name);
3947
+ if (!p) { if (lib.size) report('76', 'WARN', doc.file, doc.lineOf(m.index), `a Commons file that is not in the library (${trunc(decodeURIComponent(name), 40)})`, 'Pick photographs with theme.mjs photos, or check its licence and credit it by hand'); }
3948
+ else if (!credited(p)) report('76', 'FAIL', doc.file, doc.lineOf(m.index), `a library photo with no credit on the page (${trunc(p.author, 30)}, ${p.license})`, 'Add its printed credit to the footer\'s Photographs line (imagery.md § 2)');
3949
+ }
3950
+ for (const m of doc.src.matchAll(SAVED)) {
3951
+ const p = lib.get(m[1]);
3952
+ if (!p || seen.has(m[1])) continue;
3953
+ seen.add(m[1]);
3954
+ if (!credited(p)) report('76', 'FAIL', doc.file, doc.lineOf(m.index), `a library photo with no credit on the page (${trunc(p.author, 30)}, ${p.license})`, 'Add its printed credit to the footer\'s Photographs line (imagery.md § 2)');
3955
+ }
3956
+ if (doc.kind !== 'html') continue;
3957
+ for (const m of doc.src.matchAll(/<img\b([^>]*)>/gi)) {
3958
+ const src = getAttr(m[1], 'src');
3959
+ if ((src == null || !src.trim() || src.trim() === '#') && !getAttr(m[1], 'srcset') && !hasAttr(m[1], 'data-src')) { report('76', 'FAIL', doc.file, doc.lineOf(m.index), 'an <img> with no source', 'Give it the real image, make it a data-slot placeholder, or remove it'); break; }
3960
+ }
3961
+ }
3962
+ }
3963
+
3964
+ const CHECKS = [check1, check2, check3, check3Stripe, check4, check6a, check7, check10, check10Motion, check11,
3965
+ check12, check15, check17, check18, check19, check20, check22, check23, check24, check24a,
3966
+ check25, check26, check27, check28, check30, check33, check34, check37, check38a,
3967
+ check39, check40_41, check42, check42Footer, check46, check47, check48, check49,
3968
+ check50, check50Wrap, check53, check54, check55, check56, checkCopyTell, check62, check63, check64, check65, check67, check38c,
3969
+ check19Tells, check58Static, check68, check69, check70, check42TwoButton, check47Placeholders, check65Tailwind, check29Glow, check11Lift, check15Fade,
3970
+ checkF1, checkF2, checkF3, checkF4, checkF5, checkF6, checkF7, checkF7Pills, checkF8, checkF9, checkF15, checkF16, checkF21, checkF22, checkF23, checkF24, check76,
3971
+ checkTailwind, checkComponentText];
3972
+
3973
+ const files = opts.paths.length ? collectFiles(opts.paths) : [];
3974
+ if (!files.length && !opts.urls.length) { console.error('sloplint: no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found'); process.exit(2); }
3975
+
3976
+ const groups = new Map();
3977
+ for (const f of files) {
3978
+ const dir = dirname(f);
3979
+ if (!groups.has(dir)) groups.set(dir, { css: [], html: [] });
3980
+ const g = groups.get(dir);
3981
+ if (extname(f).toLowerCase() === '.css') g.css.push(f);
3982
+ else g.html.push(f);
3983
+ }
3984
+
3985
+ const localRef = (href) => href && !/^(?:[a-z]+:|\/\/)/i.test(href) ? href.split(/[?#]/)[0] : null;
3986
+ function importsOf(css, from) {
3987
+ const out = [];
3988
+ for (const m of stripComments(css).matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?/gi)) {
3989
+ const ref = localRef(m[1]);
3990
+ if (ref) out.push(resolve(from, ref));
3991
+ }
3992
+ return out;
3993
+ }
3994
+ function linksOf(doc, from) {
3995
+ const out = [];
3996
+ for (const t of doc.tags) {
3997
+ if (t.close || t.name !== 'link' || !/stylesheet/i.test(getAttr(t.attrs, 'rel') || '')) continue;
3998
+ const ref = localRef(getAttr(t.attrs, 'href'));
3999
+ if (ref) out.push(resolve(from, ref));
4000
+ }
4001
+ return out;
4002
+ }
4003
+
4004
+ const projectRoots = new Map();
4005
+ function projectRoot(dir) {
4006
+ if (projectRoots.has(dir)) return projectRoots.get(dir);
4007
+ const up = dirname(dir);
4008
+ const root = existsSync(join(dir, 'package.json')) ? dir : up === dir ? null : projectRoot(up);
4009
+ projectRoots.set(dir, root);
4010
+ return root;
4011
+ }
4012
+ function shareProjects(contexts) {
4013
+ const byRoot = new Map();
4014
+ for (const [dir, ctx] of contexts) {
4015
+ const root = projectRoot(dir);
4016
+ if (!root) continue;
4017
+ if (!byRoot.has(root)) byRoot.set(root, []);
4018
+ byRoot.get(root).push(ctx);
4019
+ }
4020
+ for (const list of byRoot.values()) {
4021
+ const parts = list.filter((c) => !c.cssFiles.length && c.docs.length && c.docs.every((d) => d.kind !== 'html'));
4022
+ if (!parts.length || parts.length === list.length) continue;
4023
+ const tokens = {};
4024
+ const shared = { focus: false, active: false, disabled: false, prm: false, once: new Set() };
4025
+ for (const c of list) {
4026
+ Object.assign(tokens, c.tokens);
4027
+ const tv = twGroup(c).variants;
4028
+ const sel = (re) => c.rules.some((r) => re.test(r.selector));
4029
+ shared.focus = shared.focus || sel(/:focus/) || [...tv].some((v) => /^focus/.test(v));
4030
+ shared.active = shared.active || sel(/:active/) || tv.has('active');
4031
+ shared.disabled = shared.disabled || sel(/:disabled|\[disabled\]|aria-disabled/) || tv.has('disabled') || tv.has('aria-disabled');
4032
+ shared.prm = shared.prm || /prefers-reduced-motion/i.test(stripComments(c.allCss)) || tv.has('motion-reduce') || tv.has('motion-safe');
4033
+ }
4034
+ for (const c of list) c.shared = shared;
4035
+ const stamped = list.find((c) => !parts.includes(c) && /Hallmark\s*·/.test(c.stampText));
4036
+ for (const c of parts) {
4037
+ if (stamped && !/Hallmark\s*·/.test(c.stampText)) c.stampText = `${c.stampText}\n${stamped.stampText}`;
4038
+ c.twTok = { ...tokens, ...c.tokens };
4039
+ c._tw = null;
4040
+ }
4041
+ }
4042
+ }
4043
+
4044
+ const waiversByKey = new Map();
4045
+ const postureByFile = {};
4046
+ const heroPostureByFile = {};
4047
+ const photoShapeByFile = {};
4048
+ const jobByFile = {};
4049
+ const contexts = [];
4050
+ const parsedDocs = new Map();
4051
+ const docOf = (f) => {
4052
+ if (parsedDocs.has(f)) return parsedDocs.get(f);
4053
+ let doc = null;
4054
+ try { doc = parseDoc(f, readFileSync(f, 'utf8')); } catch (e) { console.error(`sloplint: skipped ${rel(f)}, it could not be parsed (${e.message})`); }
4055
+ parsedDocs.set(f, doc);
4056
+ return doc;
4057
+ };
4058
+ function buildContext(key, cssList, docList) {
4059
+ const cssFiles = [];
4060
+ const rules = [];
4061
+ const docs = [];
4062
+ const inGroup = new Set([...cssList, ...docList.map((d) => d.file)]);
4063
+ const pending = [];
4064
+ for (const f of cssList) {
4065
+ const raw = readFileSync(f, 'utf8');
4066
+ cssFiles.push({ file: f, raw });
4067
+ rules.push(...parseCss(raw, f));
4068
+ pending.push(...importsOf(raw, dirname(f)));
4069
+ }
4070
+ for (const doc of docList) {
4071
+ docs.push(doc);
4072
+ for (const st of doc.styles) {
4073
+ rules.push(...parseCss(st.css, doc.file, st.lineOffset));
4074
+ pending.push(...importsOf(st.css, dirname(doc.file)));
4075
+ }
4076
+ pending.push(...linksOf(doc, dirname(doc.file)));
4077
+ }
4078
+ const reached = new Set();
4079
+ while (pending.length) {
4080
+ const p = pending.shift();
4081
+ if (inGroup.has(p) || extname(p).toLowerCase() !== '.css' || !existsSync(p)) continue;
4082
+ inGroup.add(p);
4083
+ reached.add(p);
4084
+ let raw;
4085
+ try { raw = readFileSync(p, 'utf8'); } catch { continue; }
4086
+ cssFiles.push({ file: p, raw, attached: true });
4087
+ rules.push(...parseCss(raw, p));
4088
+ pending.push(...importsOf(raw, dirname(p)));
4089
+ }
4090
+ const head40 = (t) => t.split('\n').slice(0, 40).join('\n');
4091
+ const stampText = [
4092
+ ...cssFiles.map((c) => head40(c.raw)),
4093
+ ...docs.flatMap((d) => d.styles.map((st) => head40(st.css))),
4094
+ ].join('\n');
4095
+ const posture = (/posture:\s*(restrained|committed|full-palette|drenched)/i.exec(stampText)?.[1] || 'restrained').toLowerCase();
4096
+ // a custom page designs its own hero: 44a and 44c read it as free
4097
+ const heroPosture = /theme:\s*custom\b/i.test(stampText) ? 'custom' : (/hero:\s*(settled|banner|poster|ledge|corridor|stage|counter|index)\b/i.exec(stampText)?.[1] || '').toLowerCase();
4098
+ const photoShape = /shape:\s*(photographic|portfolio)/i.test(stampText);
4099
+ const job = ((/\bjob:\s*([a-z][a-z /-]*)/i.exec(stampText) || [])[1] || '').trim().toLowerCase();
4100
+ for (const d of docs) { postureByFile[d.file] = posture; heroPostureByFile[d.file] = heroPosture; photoShapeByFile[d.file] = photoShape; jobByFile[d.file] = job; }
4101
+ const allCss = cssFiles.map((c) => c.raw).join('\n') + '\n'
4102
+ + docs.map((d) => d.styles.map((st) => st.css).join('\n')).join('\n');
4103
+ const tokensOnly = !docs.length && rules.length > 0 && rules.every(isTokenRule);
4104
+ contexts.push([key, { rules, tokens: buildTokens(rules), docs, cssFiles, genre: opts.genre, stampText, allCss, posture, tokensOnly }]);
4105
+ return reached;
4106
+ }
4107
+ for (const [dir, g] of groups) {
4108
+ const docs = g.html.map(docOf).filter(Boolean);
4109
+ const pages = docs.filter((d) => d.kind === 'html');
4110
+ const parts = docs.filter((d) => d.kind !== 'html');
4111
+ if (!pages.length) { buildContext(dir, g.css, parts); continue; }
4112
+ const used = new Set();
4113
+ for (const page of pages) {
4114
+ const linked = new Set();
4115
+ const queue = [...linksOf(page, dirname(page.file)), ...page.styles.flatMap((st) => importsOf(st.css, dirname(page.file)))];
4116
+ while (queue.length) {
4117
+ const q = queue.shift();
4118
+ if (linked.has(q) || !existsSync(q) || extname(q).toLowerCase() !== '.css') continue;
4119
+ linked.add(q);
4120
+ try { queue.push(...importsOf(readFileSync(q, 'utf8'), dirname(q))); } catch {}
4121
+ }
4122
+ const own = pages.length === 1 ? g.css : g.css.filter((c) => linked.has(c));
4123
+ own.forEach((c) => used.add(c));
4124
+ for (const c of linked) used.add(c);
4125
+ buildContext(page.file, own, [page]);
4126
+ }
4127
+ const rest = g.css.filter((c) => !used.has(c));
4128
+ if (rest.length || parts.length) buildContext(dir, rest, parts);
4129
+ }
4130
+ shareProjects(contexts);
4131
+ for (const [key, ctx] of contexts) {
4132
+ applyWaivers(ctx, key);
4133
+ waiversByKey.set(key, { WAIVED, ANSWERED, DISMISSED, VERBATIM, APPSCREEN, LAYOUT_KEPT });
4134
+ for (const check of CHECKS) {
4135
+ try { check(ctx); } catch (e) {
4136
+ console.error(`sloplint: internal error in ${check.name} for ${rel(key)}: ${e.message}`);
4137
+ }
4138
+ }
4139
+ }
4140
+
4141
+ let renderNotRun = null;
4142
+ let domInventories = {};
4143
+ if (opts.render) {
4144
+ const isShell = (f) => { try { const x = readFileSync(f, 'utf8'); return /<script\b[^>]*type\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["'][^"']+\.(?:tsx?|jsx?|mjs|vue|svelte)["']/i.test(x); } catch { return false; } };
4145
+ const htmlFiles = files.filter((f) => extname(f).toLowerCase() === '.html' && !(opts.urls.length && isShell(f)));
4146
+ if (!htmlFiles.length && !opts.urls.length) renderNotRun = 'no HTML page to render; pass --url for a running dev server';
4147
+ else try {
4148
+ const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);
4149
+ const extra = await mod.renderCheck([...htmlFiles, ...opts.urls], { genre: opts.genre, postureByFile, heroPostureByFile, photoShapeByFile, jobByFile, stage: opts.stage, selectors: opts.before || opts.kept ? Object.fromEntries([...new Set(contexts.flatMap(([, c]) => c.docs))].filter((d) => d.kind === 'html').map((d) => [d.file, scriptSelectors(d)])) : {}, scope: opts.scope === 'app' || [...waiversByKey.values()].some((x) => x.APPSCREEN) ? 'app' : opts.scope, before: opts.before });
4150
+ domInventories = extra.inventories || {};
4151
+ const allWaivers = { WAIVED: new Set([...waiversByKey.values()].flatMap((x) => [...x.WAIVED])), ANSWERED: new Set([...waiversByKey.values()].flatMap((x) => [...x.ANSWERED])), DISMISSED: new Set([...waiversByKey.values()].flatMap((x) => [...(x.DISMISSED || [])])), VERBATIM: [...waiversByKey.values()].some((x) => x.VERBATIM), APPSCREEN: [...waiversByKey.values()].some((x) => x.APPSCREEN), LAYOUT_KEPT: [...waiversByKey.values()].map((x) => x.LAYOUT_KEPT).find(Boolean) || false };
4152
+ for (const f of extra) {
4153
+ const w = /^https?:/.test(f.file) ? allWaivers : (waiversByKey.get(f.file) || waiversByKey.get(dirname(f.file)));
4154
+ WAIVED = w ? w.WAIVED : new Set();
4155
+ ANSWERED = w ? w.ANSWERED : new Set();
4156
+ DISMISSED = w ? (w.DISMISSED || new Set()) : new Set();
4157
+ VERBATIM = !!(w && w.VERBATIM);
4158
+ APPSCREEN = !!(w && w.APPSCREEN);
4159
+ LAYOUT_KEPT = (w && w.LAYOUT_KEPT) || false;
4160
+ report(f.gate, f.grade === 'REFLEX' ? 'FAIL' : f.grade, f.file, f.line, f.evidence, f.fix);
4161
+ }
4162
+ for (const x of extra.failed || []) { WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set(); VERBATIM = false; APPSCREEN = false; LAYOUT_KEPT = false; report('render', 'FAIL', x.file, 1, `the page did not render (${trunc(x.message, 60)}); its render gates did not run`, 'Fix the page or the URL and rerun; an unrendered page is never a pass'); }
4163
+ if (extra.notRun) renderNotRun = extra.notRun;
4164
+ } catch (e) {
4165
+ renderNotRun = e.message;
4166
+ }
4167
+ }
4168
+
4169
+ const SNAP = join(process.cwd(), '.hallmark', 'before.json');
4170
+ const squash = (t) => t.replace(/\s+/g, ' ').trim();
4171
+ const escRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4172
+ function wiringOf(src) {
4173
+ const body = src.replace(/<!--[\s\S]*?-->/g, '');
4174
+ const w = { handlers: [], links: [], fields: [], hooks: [], ids: [], scripts: [] };
4175
+ const val = String.raw`("[^"]*"|'[^']*'|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})`;
4176
+ for (const m of body.matchAll(new RegExp(String.raw`(?<![\w-])(on[A-Z]\w*|on:[\w|]+|@[a-z][\w.:-]*|v-on:[\w.:-]+|on(?:click|submit|change|input|keydown|keyup|blur|focus))\s*=\s*` + val, 'g'))) w.handlers.push(`${m[1]}=${squash(m[2])}`);
4177
+ for (const m of body.matchAll(new RegExp(String.raw`(?<![\w:-])(?:href|to|action)\s*=\s*` + val, 'g'))) {
4178
+ const tag = (/<([A-Za-z][\w.-]*)[^<]*$/.exec(body.slice(Math.max(0, m.index - 600), m.index)) || [])[1] || '';
4179
+ if (/^(link|base|use|image|script|style)$/.test(tag)) continue;
4180
+ const v = squash(m[1]).replace(/^["']|["']$/g, '').replace(/^\.\//, '');
4181
+ if (v && v !== '#') w.links.push(v);
4182
+ }
4183
+ for (const m of body.matchAll(/(?<![\w-])name\s*=\s*["']([^"']+)["']/g)) {
4184
+ const tag = (/<([A-Za-z][\w.-]*)[^<]*$/.exec(body.slice(Math.max(0, m.index - 600), m.index)) || [])[1] || '';
4185
+ if (!/^(meta|slot|iframe|param|a|map|object|link)$/i.test(tag)) w.fields.push(m[1]);
4186
+ }
4187
+ for (const m of body.matchAll(/\bregister\(\s*["']([\w.-]+)["']/g)) w.fields.push(m[1]);
4188
+ for (const m of body.matchAll(new RegExp(String.raw`\bdata-(?:testid|test|cy|qa|track[\w-]*|analytics[\w-]*|event[\w-]*|gtm[\w-]*)\s*=\s*` + val, 'g'))) w.hooks.push(squash(m[1]).replace(/^["']|["']$/g, ''));
4189
+ for (const m of body.matchAll(/(?<![\w-])id\s*=\s*["']([^"'{}\s]+)["']/g)) w.ids.push(m[1]);
4190
+ for (const m of body.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)) w.scripts.push(m[1].split('?')[0].replace(/^\.\//, ''));
4191
+ return w;
4192
+ }
4193
+ function scriptText(doc) {
4194
+ const raw = doc.raw || doc.src;
4195
+ let js = [...raw.matchAll(/<script\b(?![^>]*\bsrc\s*=)(?![^>]*application\/(?:ld\+)?json)[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]).join('\n');
4196
+ for (const m of raw.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)) {
4197
+ if (/^(?:https?:)?\/\//.test(m[1])) continue;
4198
+ try { js += '\n' + readFileSync(resolve(dirname(doc.file), m[1].split('?')[0]), 'utf8'); } catch {}
4199
+ }
4200
+ return js;
4201
+ }
4202
+ function scriptSelectors(doc) {
4203
+ const js = scriptText(doc);
4204
+ const sels = new Set();
4205
+ for (const m of js.matchAll(/getElementById\(\s*["'`]([\w-]+)["'`]/g)) sels.add('#' + m[1]);
4206
+ for (const m of js.matchAll(/(?:querySelector(?:All)?|closest|matches)\(\s*(["'`])((?:(?!\1)[^\\\n])+)\1/g)) if (!m[2].includes('${')) sels.add(m[2].trim());
4207
+ return [...sels].sort();
4208
+ }
4209
+ function scriptHooks(doc) {
4210
+ const markup = (doc.raw || doc.src).replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
4211
+ const has = (tok) => tok[0] === '#' ? new RegExp(`(?<![\\w-])id\\s*=\\s*["']${escRe(tok.slice(1))}["']`).test(markup)
4212
+ : tok[0] === '.' ? new RegExp(`(?<![\\w-])class\\s*=\\s*["'][^"']*(?<![\\w-])${escRe(tok.slice(1))}(?![\\w-])`).test(markup)
4213
+ : markup.includes(tok.slice(1));
4214
+ const queried = scriptSelectors(doc);
4215
+ const tokens = (sel) => [...sel.matchAll(/#[\w-]+|\.[A-Za-z_][\w-]*|\[data-[\w-]+/g)].map((t) => t[0]);
4216
+ return { queried, hooked: queried.filter((sel) => { const t = tokens(sel); return t.length > 0 && t.every(has); }) };
4217
+ }
4218
+ function takeInventory() {
4219
+ const docs = [...new Set(contexts.flatMap(([, c]) => c.docs))];
4220
+ const byFile = {};
4221
+ for (const d of docs) { const w = wiringOf(d.raw || d.src); byFile[rel(d.file)] = Object.fromEntries(Object.entries(w).map(([k, v]) => [k, [...new Set(v)].sort()])); }
4222
+ const pages = {};
4223
+ for (const [file, inv] of Object.entries(domInventories)) if (inv) pages[/^https?:/.test(file) ? file : rel(file)] = inv;
4224
+ const scripts = {};
4225
+ for (const d of docs) if (d.kind === 'html') {
4226
+ const h = scriptHooks(d);
4227
+ const dom = domInventories[d.file];
4228
+ if (dom && Array.isArray(dom.hooked)) h.dom = dom.hooked;
4229
+ h.hides = /\.hidden\s*=|["'`]hidden["'`]/.test(scriptText(d));
4230
+ if (h.queried.length || h.hides) scripts[rel(d.file)] = h;
4231
+ }
4232
+ return { files: byFile, wiring: unionWiring(Object.values(byFile)), scripts, pages };
4233
+ }
4234
+ function unionWiring(list) {
4235
+ const u = { handlers: new Set(), links: new Set(), fields: new Set(), hooks: new Set(), ids: new Set() };
4236
+ for (const w of list) for (const k of Object.keys(u)) for (const v of w[k] || []) u[k].add(v);
4237
+ return Object.fromEntries(Object.entries(u).map(([k, v]) => [k, [...v].sort()]));
4238
+ }
4239
+ let keptLine = null;
4240
+ if (opts.kept) {
4241
+ let was = null;
4242
+ try { was = JSON.parse(readFileSync(SNAP, 'utf8')); } catch {}
4243
+ WAIVED = new Set(); ANSWERED = new Set(); DISMISSED = new Set(); VERBATIM = false; APPSCREEN = false; LAYOUT_KEPT = false;
4244
+ if (!was) report('kept', 'FAIL', SNAP, 1, 'no before record here; --kept has nothing to compare against', 'Run sloplint with --before from this folder before the redesign starts');
4245
+ else {
4246
+ const now = takeInventory();
4247
+ const outside = Object.keys(was.files || {}).filter((f) => !(f in now.files) && existsSync(resolve(f)));
4248
+ if (was.files) was.wiring = unionWiring(Object.entries(was.files).filter(([f]) => !outside.includes(f)).map(([, w]) => w));
4249
+ const stamps = contexts.map(([, c]) => c.stampText).join('\n');
4250
+ for (const m of stamps.matchAll(/\/\*\s*Hallmark\s*·\s*(dropped|reworded)\s+([^·*]+?)\s*·\s*([^*]+?)\s*\*\//gi)) if (m[2].trim() && m[3].trim().length < 12) report('kept', 'WARN', SNAP, 1, `${m[1]} ${trunc(m[2].trim(), 40)} is not on the record: its reason is under 12 characters`, 'Say why in a clause');
4251
+ const allowed = [...stamps.matchAll(/\/\*\s*Hallmark\s*·\s*(dropped|reworded)\s+([^·*]+?)\s*·\s*([^*]+?)\s*\*\//gi)].filter((m) => m[3].trim().length >= 12).flatMap((m) => (m[1].toLowerCase() === 'reworded' ? [...m[2].matchAll(/"([^"]+)"/g)].map((q) => q[1]) : m[2].split(',')).map((t) => t.trim().replace(/^[`"']|[`"']$/g, '').toLowerCase()).filter(Boolean));
4252
+ const acked = {};
4253
+ const gone = (a, b, cat) => (a || []).filter((x) => {
4254
+ if ((b || []).includes(x)) return false;
4255
+ if (allowed.some((t) => x.toLowerCase() === t || (t.length >= 4 && x.toLowerCase().includes(t)))) { if (cat) (acked[cat] ||= new Set()).add(x); return false; }
4256
+ return true;
4257
+ });
4258
+ const fieldsGone = (b, a, where) => {
4259
+ const named = (b || []).filter((x) => !x.startsWith('unnamed '));
4260
+ const spare = (a || []).length - named.filter((x) => (a || []).includes(x)).length;
4261
+ const short = Math.max(0, (b || []).length - named.length - spare);
4262
+ return [...gone(named, a, 'fields'), ...(short ? [`${short} field${short > 1 ? 's' : ''} with no name${where}`] : [])];
4263
+ };
4264
+ const hard = { links: new Set(), fields: new Set(), forms: new Set(), handlers: new Set(), 'test hooks': new Set(), scripts: new Set() };
4265
+ const soft = { headings: new Set(), figures: new Set(), images: new Set(), ids: new Set() };
4266
+ const total = { links: new Set(was.wiring.links), fields: new Set(was.wiring.fields), handlers: new Set(was.wiring.handlers), 'test hooks': new Set(was.wiring.hooks), headings: new Set(), figures: new Set() };
4267
+ for (const x of gone(was.wiring.links, now.wiring.links, 'links')) hard.links.add(x);
4268
+ for (const x of gone(was.wiring.fields, now.wiring.fields, 'fields')) hard.fields.add(x);
4269
+ for (const x of gone(was.wiring.handlers, now.wiring.handlers, 'handlers')) hard.handlers.add(x);
4270
+ for (const x of gone(was.wiring.hooks, now.wiring.hooks, 'test hooks')) hard['test hooks'].add(x);
4271
+ for (const x of gone(was.wiring.ids, now.wiring.ids)) soft.ids.add(x);
4272
+ for (const [f, w] of Object.entries(was.files || {})) if (now.files[f]) for (const x of gone(w.scripts, now.files[f].scripts)) hard.scripts.add(`${x} on ${f}`);
4273
+ const skipped = [];
4274
+ let wordsWas = 0, wordsNow = 0;
4275
+ for (const [k, b] of Object.entries(was.pages || {})) {
4276
+ const a = now.pages[k];
4277
+ if (!a) { skipped.push(k); continue; }
4278
+ for (const x of b.links || []) total.links.add(x);
4279
+ for (const x of b.fields || []) total.fields.add(x);
4280
+ for (const x of b.headings || []) total.headings.add(x);
4281
+ for (const x of b.figures || []) total.figures.add(x);
4282
+ for (const x of gone(b.links, [...a.links, ...now.wiring.links], 'links')) hard.links.add(x);
4283
+ for (const x of fieldsGone(b.fields, a.fields, ` on ${k}`)) hard.fields.add(x);
4284
+ for (const x of gone(b.actions, a.actions)) hard.forms.add(x);
4285
+ for (const x of gone(b.headings, a.headings, 'headings')) soft.headings.add(x);
4286
+ for (const x of gone(b.figures, a.figures, 'figures')) soft.figures.add(x);
4287
+ for (const x of gone(b.images, a.images)) soft.images.add(x);
4288
+ wordsWas += b.words || 0; wordsNow += a.words || 0;
4289
+ }
4290
+ const list = (set, n = 8) => { const a = [...set]; return a.slice(0, n).map((x) => trunc(x, 48)).join(', ') + (a.length > n ? ` and ${a.length - n} more` : ''); };
4291
+ const fix = 'Restore each one, or record it in the stamp: /* Hallmark · dropped <item>, <item> · <the reason> */';
4292
+ total['script hooks'] = new Set(); hard['script hooks'] = new Set();
4293
+ for (const [f, h] of Object.entries(was.scripts || {})) {
4294
+ if (outside.includes(f)) continue;
4295
+ const n = now.scripts[f];
4296
+ const both = n && h.dom && n.dom;
4297
+ const had = both ? h.dom : h.hooked;
4298
+ for (const x of had) total['script hooks'].add(`${f} ${x}`);
4299
+ const unhooked = n ? had.filter((x) => n.queried.includes(x) && !(both ? n.dom : n.hooked).includes(x)) : [];
4300
+ for (const x of unhooked) hard['script hooks'].add(`${f} ${x}`);
4301
+ }
4302
+ const guard = contexts.some(([, c]) => c.rules.some((r) => /\[hidden\]/.test(r.selector) && r.decls.some((d) => d.prop === 'display' && /none/.test(d.value))));
4303
+ if (!guard && Object.values(now.scripts).some((h) => h.hides)) report('kept', 'FAIL', SNAP, 1, 'a script hides elements with the hidden attribute and no rule pins it, so any display rule shows them again', 'Add [hidden] { display: none !important; } to the stylesheet');
4304
+ for (const [k, b] of Object.entries(was.pages || {})) {
4305
+ const a = now.pages[k];
4306
+ if (!a) continue;
4307
+ for (const bf of b.forms || []) {
4308
+ const af = (a.forms || []).find((x) => x.key === bf.key) || (a.forms || []).find((x) => x.attrs.join() === bf.attrs.join());
4309
+ if (!af) { if (!allowed.includes(bf.key.toLowerCase())) hard.forms.add(`the form ${bf.key} on ${k}`); continue; }
4310
+ for (const x of fieldsGone(bf.fields, af.fields, '')) hard.fields.add(`${x} in ${bf.key} on ${k}`);
4311
+ for (const x of bf.hidden) if (!af.hidden.includes(x)) hard.fields.add(`hidden ${x} in ${bf.key} on ${k}`);
4312
+ for (const x of bf.attrs) if (!af.attrs.includes(x)) hard.forms.add(`${x} on ${bf.key} (${k})`);
4313
+ if (bf.submits && !af.submits) hard.forms.add(`a submit control in ${bf.key} (${k})`);
4314
+ for (const x of bf.controls || []) if (!(af.controls || []).includes(x) && !(!x.includes('[name=') && (af.controls || []).some((y) => y.startsWith(`${x}[name=`))) && !allowed.some((t) => x.toLowerCase().includes(t) && t.length >= 4)) hard.forms.add(`${x} in ${bf.key} (${k})`);
4315
+ }
4316
+ }
4317
+ for (const x of [...hard.fields]) if ([...hard.fields].some((y) => y.startsWith(`${x} in `))) hard.fields.delete(x);
4318
+ for (const [k, set] of Object.entries(hard)) if (set.size) report('kept', 'FAIL', SNAP, 1, k === 'script hooks' ? `a script still queries what its page no longer has: ${list(set)}` : k === 'forms' ? `a form lost what it had before: ${list(set)}` : `${k} the page had before and no longer has: ${list(set)}`, k === 'script hooks' ? 'Keep the ids, classes and attributes scripts query, or change the script with the markup' : fix);
4319
+ for (const [k, set] of Object.entries(soft)) if (set.size) report('kept', 'WARN', SNAP, 1, `${k} gone or reworded since the before record: ${list(set)}`, 'Restore it, or record it in the stamp: /* Hallmark · reworded "<old>" to "<new>" · <the gate that named it> */ or dropped <item> · <the reason>');
4320
+ if (wordsWas > 80 && wordsNow < wordsWas * 0.6) report('kept', 'WARN', SNAP, 1, `the pages hold ${wordsNow} words where they held ${wordsWas}`, 'Check that no section of content was dropped');
4321
+ if (skipped.length) report('kept', 'WARN', SNAP, 1, `not compared, because this run did not render them: ${list(skipped, 4)}`, 'Render the same pages or URLs the before record holds');
4322
+ if (outside.length) report('kept', 'WARN', SNAP, 1, `not compared, because this run did not read them: ${list(outside, 4)}`, 'Run --kept on the same target as --before');
4323
+ const lostOf = (k) => (hard[k] || soft[k]).size;
4324
+ keptLine = { parts: ['links', 'fields', 'handlers', 'test hooks', 'script hooks', 'headings', 'figures'].filter((k) => total[k].size).map((k) => { const d = acked[k] ? acked[k].size : 0; return `${k} ${Math.max(0, total[k].size - lostOf(k) - d)}/${total[k].size}${d ? ` (${d} dropped on record)` : ''}`; }), was: was.summary || null };
4325
+ }
4326
+ }
4327
+
4328
+ const seen = new Set();
4329
+ const tierOk = (f) => !opts.tier || (opts.tier === 'floor' ? (f.tier === 'floor' && f.grade === 'FAIL') : f.tier === opts.tier);
4330
+ const unique = findings.filter((f) => {
4331
+ if (!tierOk(f)) return false;
4332
+ const k = `${f.gate}|${f.grade}|${f.file}|${f.line}|${f.evidence}`;
4333
+ if (seen.has(k)) return false;
4334
+ seen.add(k);
4335
+ return true;
4336
+ });
4337
+
4338
+ unique.sort((a, b) => {
4339
+ const ra = GRADE_RANK[a.grade] ?? 9, rb = GRADE_RANK[b.grade] ?? 9;
4340
+ if (ra !== rb) return ra - rb;
4341
+ const ga = gateSortKey(a.gate), gb = gateSortKey(b.gate);
4342
+ if (ga !== gb) return ga - gb;
4343
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
4344
+ return a.line - b.line;
4345
+ });
4346
+
4347
+ const count = (g) => unique.filter((f) => f.grade === g).length;
4348
+ const fails = count('FAIL');
4349
+ const reflex = count('REFLEX');
4350
+ const warns = count('WARN');
4351
+ const waived = count('WAIVED');
4352
+ const rejected = count('WAIVER');
4353
+ const answered = count('ANSWERED');
4354
+ const dismissed = count('DISMISSED');
4355
+ const notes = count('NOTE');
4356
+
4357
+ const tally = [`${fails} FAIL`];
4358
+ if (reflex) tally.push(`${reflex} REFLEX`);
4359
+ tally.push(`${warns} WARN`);
4360
+ if (waived) tally.push(`${waived} WAIVED`);
4361
+ if (rejected) tally.push(`${rejected} REJECTED`);
4362
+ if (answered) tally.push(`${answered} ANSWERED`);
4363
+ if (dismissed) tally.push(`${dismissed} DISMISSED`);
4364
+ if (notes) tally.push(`${notes} NOTE`);
4365
+
4366
+ let beforeLine = null;
4367
+ if (opts.before) {
4368
+ const inv = takeInventory();
4369
+ mkdirSync(dirname(SNAP), { recursive: true });
4370
+ writeFileSync(SNAP, JSON.stringify({ v: 1, at: new Date().toISOString(), scope: opts.scope, summary: { fails, reflex, warns }, ...inv }, null, 1) + '\n');
4371
+ beforeLine = `before: recorded ${new Set([...inv.wiring.links, ...Object.values(inv.pages).flatMap((p) => p.links)]).size} links, ${new Set([...inv.wiring.fields, ...Object.values(inv.pages).flatMap((p) => p.fields)]).size} fields, ${inv.wiring.handlers.length} handlers, ${inv.wiring.hooks.length} test hooks, ${new Set(Object.values(inv.scripts).flatMap((h) => h.hooked)).size} script hooks${Object.keys(inv.pages).length ? ` and ${Object.keys(inv.pages).length} rendered page(s)` : ''} in ${rel(SNAP)}; --kept checks a redesign against it`;
4372
+ }
4373
+ const keptNote = keptLine ? `kept: ${keptLine.parts.join(', ') || 'nothing recorded'}${keptLine.was ? `; before ${keptLine.was.fails} FAIL, ${keptLine.was.reflex} REFLEX, now ${fails} FAIL, ${reflex} REFLEX` : ''}` : null;
4374
+ let compareNote = null;
4375
+ if (opts.kept && opts.render && !renderNotRun) {
4376
+ try {
4377
+ const mod = await import(new URL('./sloplint-render.mjs', import.meta.url).href);
4378
+ const made = mod.writeCompare([...files.filter((f) => extname(f).toLowerCase() === '.html'), ...opts.urls], keptNote ? keptNote.replace(/^kept: /, 'Kept: ').replace(/; before (\d+) FAIL, (\d+) REFLEX, now (\d+) FAIL, (\d+) REFLEX/, '. Floor findings: $1 before, $3 now. Reflex findings: $2 before, $4 now') + '.' : null);
4379
+ if (made.length) compareNote = `compare: ${made.map(rel).join(', ')} shows before and after side by side`;
4380
+ } catch {}
4381
+ }
4382
+ const scopeNote = `${files.length} file(s)${opts.urls.length ? ` and ${opts.urls.length} URL(s)` : ''}${opts.stage === 'hero' ? ' (hero stage)' : ''}`;
4383
+ const renderNote = opts.render ? (renderNotRun ? `render tier not run: ${renderNotRun}` : null) : null;
4384
+ if (opts.json) {
4385
+ console.log(JSON.stringify({
4386
+ summary: { fails, reflex, warns, waived, rejected, answered, dismissed, notes, files: files.length, render: opts.render ? (renderNotRun ? `not run: ${renderNotRun}` : 'ran') : 'off', ...(keptNote ? { kept: keptNote } : {}), ...(beforeLine ? { before: beforeLine } : {}) },
4387
+ findings: unique,
4388
+ }, null, 2));
4389
+ } else {
4390
+ if (!unique.length) console.log(`sloplint: clean. 0 FAIL, 0 REFLEX, 0 WARN across ${scopeNote}.`);
4391
+ else if (opts.before) {
4392
+ const by = new Map();
4393
+ for (const f of unique) { const k = `${f.grade} ${f.gate}`; if (!by.has(k)) by.set(k, []); by.get(k).push(f); }
4394
+ for (const [k, list] of by) console.log(` ${k}${list.length > 1 ? ` x${list.length}` : ''} ${list.slice(0, 2).map((f) => `${f.file}:${f.line} ${trunc(f.evidence, 64)}`).join('; ')} | fix: ${list[0].fix}`);
4395
+ const every = join(dirname(SNAP), 'before.txt');
4396
+ mkdirSync(dirname(SNAP), { recursive: true });
4397
+ const ignore = join(dirname(SNAP), '.gitignore');
4398
+ if (!existsSync(ignore)) writeFileSync(ignore, 'shots/\nbefore/\nbefore.json\nbefore.txt\ncompare.html\n');
4399
+ writeFileSync(every, unique.map((f) => `${f.grade} ${f.gate} ${f.file}:${f.line} ${f.evidence} | fix: ${f.fix}`).join('\n') + '\n');
4400
+ console.log(`sloplint: ${tally.join(', ')} across ${scopeNote}, grouped by gate; every line is in ${rel(every)}.`);
4401
+ } else {
4402
+ const byFile = new Map();
4403
+ for (const f of unique) { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); }
4404
+ const told = new Set();
4405
+ for (const [file, list] of byFile) {
4406
+ console.log(file);
4407
+ for (const f of list) {
4408
+ const key = `${f.gate}|${f.fix}`;
4409
+ const fix = f.grade !== 'WAIVED' && f.grade !== 'ANSWERED' && f.grade !== 'DISMISSED' && !told.has(key) ? ` | fix: ${f.fix}` : '';
4410
+ told.add(key);
4411
+ console.log(` ${f.grade} ${f.gate} :${f.line} ${f.evidence}${fix}`);
4412
+ }
4413
+ }
4414
+ console.log(`sloplint: ${tally.join(', ')} across ${scopeNote}.`);
4415
+ }
4416
+ if (renderNote) console.log(renderNote);
4417
+ if (keptNote) console.log(keptNote);
4418
+ if (compareNote) console.log(compareNote);
4419
+ if (beforeLine) console.log(beforeLine);
4420
+ }
4421
+ const exitCode = !opts.before && (fails > 0 || reflex > 0) ? 1 : 0;
4422
+ if (process.stdout.writableLength) process.stdout.once('drain', () => process.exit(exitCode)); else process.exit(exitCode);