dsh-visualkit 0.0.0-stage → 0.0.1

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 (283) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +26 -0
  3. package/README.md +427 -2
  4. package/THIRD_PARTY.md +55 -0
  5. package/cordis.patch.yml +29 -0
  6. package/data/assets/README.md +38 -0
  7. package/data/assets/icons/brands/bilibili.svg +1 -0
  8. package/data/assets/icons/brands/discord.svg +1 -0
  9. package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
  10. package/data/assets/icons/brands/qq.svg +1 -0
  11. package/data/assets/icons/brands/sinaweibo.svg +1 -0
  12. package/data/assets/icons/brands/steam.svg +1 -0
  13. package/data/assets/icons/brands/telegram.svg +1 -0
  14. package/data/assets/icons/brands/tiktok.svg +1 -0
  15. package/data/assets/icons/brands/twitch.svg +1 -0
  16. package/data/assets/icons/brands/wechat.svg +1 -0
  17. package/data/assets/icons/brands/x.svg +1 -0
  18. package/data/assets/icons/brands/youtube.svg +1 -0
  19. package/data/assets/icons/lucide/bell.svg +1 -0
  20. package/data/assets/icons/lucide/calendar.svg +1 -0
  21. package/data/assets/icons/lucide/clock.svg +1 -0
  22. package/data/assets/icons/lucide/gamepad-2.svg +1 -0
  23. package/data/assets/icons/lucide/gift.svg +1 -0
  24. package/data/assets/icons/lucide/heart.svg +1 -0
  25. package/data/assets/icons/lucide/mic.svg +1 -0
  26. package/data/assets/icons/lucide/play.svg +1 -0
  27. package/data/assets/icons/lucide/star.svg +1 -0
  28. package/data/assets/icons/lucide/video.svg +1 -0
  29. package/data/assets/icons/phosphor/LICENSE +21 -0
  30. package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
  31. package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
  32. package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
  33. package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
  34. package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
  35. package/data/assets/icons/phosphor/code-duotone.svg +1 -0
  36. package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
  37. package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
  38. package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
  39. package/data/assets/icons/phosphor/database-duotone.svg +1 -0
  40. package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
  41. package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
  42. package/data/assets/icons/phosphor/function-duotone.svg +1 -0
  43. package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
  44. package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
  45. package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
  46. package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
  47. package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
  48. package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
  49. package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
  50. package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
  51. package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
  52. package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
  53. package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
  54. package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
  55. package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
  56. package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
  57. package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
  58. package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
  59. package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
  60. package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
  61. package/data/assets/icons/tabler/alarm.svg +1 -0
  62. package/data/assets/icons/tabler/badge-ad.svg +1 -0
  63. package/data/assets/icons/tabler/bell.svg +1 -0
  64. package/data/assets/icons/tabler/bolt.svg +1 -0
  65. package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
  66. package/data/assets/icons/tabler/brand-discord.svg +1 -0
  67. package/data/assets/icons/tabler/brand-qq.svg +1 -0
  68. package/data/assets/icons/tabler/brand-twitch.svg +1 -0
  69. package/data/assets/icons/tabler/brand-wechat.svg +1 -0
  70. package/data/assets/icons/tabler/brand-weibo.svg +1 -0
  71. package/data/assets/icons/tabler/brand-youtube.svg +1 -0
  72. package/data/assets/icons/tabler/broadcast.svg +1 -0
  73. package/data/assets/icons/tabler/calendar-event.svg +1 -0
  74. package/data/assets/icons/tabler/calendar-week.svg +1 -0
  75. package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
  76. package/data/assets/icons/tabler/coin.svg +1 -0
  77. package/data/assets/icons/tabler/crown.svg +1 -0
  78. package/data/assets/icons/tabler/currency-yen.svg +1 -0
  79. package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
  80. package/data/assets/icons/tabler/diamond.svg +1 -0
  81. package/data/assets/icons/tabler/flame.svg +1 -0
  82. package/data/assets/icons/tabler/gift.svg +1 -0
  83. package/data/assets/icons/tabler/headphones.svg +1 -0
  84. package/data/assets/icons/tabler/heart-filled.svg +1 -0
  85. package/data/assets/icons/tabler/heart.svg +1 -0
  86. package/data/assets/icons/tabler/live-view.svg +1 -0
  87. package/data/assets/icons/tabler/microphone.svg +1 -0
  88. package/data/assets/icons/tabler/mood-smile.svg +1 -0
  89. package/data/assets/icons/tabler/mood-tongue.svg +1 -0
  90. package/data/assets/icons/tabler/movie.svg +1 -0
  91. package/data/assets/icons/tabler/player-play.svg +1 -0
  92. package/data/assets/icons/tabler/playlist.svg +1 -0
  93. package/data/assets/icons/tabler/rocket.svg +1 -0
  94. package/data/assets/icons/tabler/sparkles.svg +1 -0
  95. package/data/assets/icons/tabler/star-filled.svg +1 -0
  96. package/data/assets/icons/tabler/star.svg +1 -0
  97. package/data/assets/icons/tabler/tag.svg +1 -0
  98. package/data/assets/icons/tabler/thumb-up.svg +1 -0
  99. package/data/assets/icons/tabler/ticket.svg +1 -0
  100. package/data/assets/icons/tabler/video.svg +1 -0
  101. package/data/assets/illustrations/openpeeps/LICENSE +8 -0
  102. package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
  103. package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
  104. package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
  105. package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
  106. package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
  107. package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
  108. package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
  109. package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
  110. package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
  111. package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
  112. package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
  113. package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
  114. package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
  115. package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
  116. package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
  117. package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
  118. package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
  119. package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
  120. package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
  121. package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
  122. package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
  123. package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
  124. package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
  125. package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
  126. package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
  127. package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
  128. package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
  129. package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
  130. package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
  131. package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
  132. package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
  133. package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
  134. package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
  135. package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
  136. package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
  137. package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
  138. package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
  139. package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
  140. package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
  141. package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
  142. package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
  143. package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
  144. package/data/assets/manifest.json +1 -0
  145. package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
  146. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
  147. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
  148. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
  149. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
  150. package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
  151. package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
  152. package/data/assets/parts//345/217/2211.png +0 -0
  153. package/data/assets/parts//345/217/2212.png +0 -0
  154. package/data/assets/parts//345/217/2213.png +0 -0
  155. package/data/assets/parts//345/217/221/345/234/210.png +0 -0
  156. package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
  157. package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
  158. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
  159. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
  160. package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
  161. package/data/assets/parts//345/217/263/346/211/213.png +0 -0
  162. package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
  163. package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
  164. package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
  165. package/data/assets/parts//345/217/263/350/205/277.png +0 -0
  166. package/data/assets/parts//345/221/206/346/257/233.png +0 -0
  167. package/data/assets/parts//345/230/264/345/267/264.png +0 -0
  168. package/data/assets/parts//345/260/276/345/267/264.png +0 -0
  169. package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
  170. package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
  171. package/data/assets/parts//345/267/246/346/211/213.png +0 -0
  172. package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
  173. package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
  174. package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
  175. package/data/assets/parts//345/267/246/350/205/277.png +0 -0
  176. package/data/assets/parts//346/212/225/345/275/261.png +0 -0
  177. package/data/assets/parts//347/234/211/346/257/233.png +0 -0
  178. package/data/assets/parts//347/234/274/347/275/251.png +0 -0
  179. package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
  180. package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
  181. package/data/assets/parts//350/204/221/345/243/263.png +0 -0
  182. package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
  183. package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
  184. package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
  185. package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
  186. package/data/assets/parts//350/272/253/344/275/223.png +0 -0
  187. package/data/assets/parts//351/223/201/346/235/277.png +0 -0
  188. package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
  189. package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
  190. package/data/assets/patterns/checker.svg +1 -0
  191. package/data/assets/patterns/circles-overlap.svg +1 -0
  192. package/data/assets/patterns/crosshatch.svg +1 -0
  193. package/data/assets/patterns/diagonal.svg +1 -0
  194. package/data/assets/patterns/dots.svg +1 -0
  195. package/data/assets/patterns/grid.svg +1 -0
  196. package/data/assets/patterns/halftone.svg +1 -0
  197. package/data/assets/patterns/plus.svg +1 -0
  198. package/data/assets/patterns/scales.svg +1 -0
  199. package/data/assets/patterns/triangles.svg +1 -0
  200. package/data/assets/patterns/waves.svg +1 -0
  201. package/data/assets/patterns/zigzag.svg +1 -0
  202. package/data/assets/stickers/openmoji/1F375.svg +12 -0
  203. package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
  204. package/data/assets/stickers/twemoji/1f389.svg +1 -0
  205. package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
  206. package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
  207. package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
  208. package/data/assets/stickers/twemoji/1f44b.svg +1 -0
  209. package/data/assets/stickers/twemoji/1f44d.svg +1 -0
  210. package/data/assets/stickers/twemoji/1f494.svg +1 -0
  211. package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
  212. package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
  213. package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
  214. package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
  215. package/data/assets/stickers/twemoji/1f525.svg +1 -0
  216. package/data/assets/stickers/twemoji/1f600.svg +1 -0
  217. package/data/assets/stickers/twemoji/1f602.svg +1 -0
  218. package/data/assets/stickers/twemoji/1f60d.svg +1 -0
  219. package/data/assets/stickers/twemoji/1f62d.svg +1 -0
  220. package/data/assets/stickers/twemoji/1f680.svg +1 -0
  221. package/data/assets/stickers/twemoji/1f979.svg +1 -0
  222. package/data/assets/stickers/twemoji/1f97a.svg +1 -0
  223. package/data/assets/stickers/twemoji/26a1.svg +1 -0
  224. package/data/assets/stickers/twemoji/2705.svg +1 -0
  225. package/data/assets/stickers/twemoji/274c.svg +1 -0
  226. package/data/assets/stickers/twemoji/2764.svg +1 -0
  227. package/data/assets/stickers/twemoji/2b50.svg +1 -0
  228. package/data/assets/stickers/twemoji_1f525.svg +1 -0
  229. package/data/assets/stickers/twemoji_1f600.svg +1 -0
  230. package/data/assets/stickers/twemoji_1f979.svg +1 -0
  231. package/data/assets/stickers/twemoji_2764.svg +1 -0
  232. package/data/assets/textures/grain-dark.png +0 -0
  233. package/data/assets/textures/grain-light.png +0 -0
  234. package/data/assets/textures/grain-warm.png +0 -0
  235. package/data/lucide-icons.json +1 -0
  236. package/data/packs/tabler.icons.json +1 -0
  237. package/icon.svg +26 -0
  238. package/lib/assets.js +228 -0
  239. package/lib/audit.js +284 -0
  240. package/lib/contrast-render.js +213 -0
  241. package/lib/css-rewrite.js +379 -0
  242. package/lib/detect.js +701 -0
  243. package/lib/diff.js +153 -0
  244. package/lib/emit.js +557 -0
  245. package/lib/html-context.js +315 -0
  246. package/lib/icon-render.js +107 -0
  247. package/lib/icondb.js +402 -0
  248. package/lib/image-normalize.js +140 -0
  249. package/lib/index.js +1289 -0
  250. package/lib/layouts/common.js +48 -0
  251. package/lib/layouts/image.js +1010 -0
  252. package/lib/layouts/index.js +10 -0
  253. package/lib/layouts/intent.js +66 -0
  254. package/lib/layouts/metrics-text.js +91 -0
  255. package/lib/layouts/png.js +75 -0
  256. package/lib/layouts/rules-contrast.js +575 -0
  257. package/lib/layouts/rules-punct.js +486 -0
  258. package/lib/layouts/rules-structure.js +182 -0
  259. package/lib/layouts/source.js +1961 -0
  260. package/lib/layouts/svg.js +1204 -0
  261. package/lib/morphcore.js +139 -0
  262. package/lib/preview.js +225 -0
  263. package/lib/render-html.js +255 -0
  264. package/lib/render.js +486 -0
  265. package/lib/rules.js +267 -0
  266. package/lib/shadow.js +83 -0
  267. package/lib/skip.js +56 -0
  268. package/lib/style.js +688 -0
  269. package/lib/svg.js +729 -0
  270. package/lib/taste-image.js +323 -0
  271. package/lib/taste.js +219 -0
  272. package/locale/en.json +6 -0
  273. package/locale/zh.json +6 -0
  274. package/package.json +92 -4
  275. package/vendor/morphicons-adapters.iife.js +903 -0
  276. package/vendor/morphicons.iife.js +2441 -0
  277. package/vendor/resvg/LICENSE +373 -0
  278. package/vendor/resvg/index.d.ts +98 -0
  279. package/vendor/resvg/index.js +23 -0
  280. package/vendor/resvg/js-binding.d.ts +52 -0
  281. package/vendor/resvg/js-binding.js +1 -0
  282. package/vendor/resvg/package.json +138 -0
  283. package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
package/lib/detect.js ADDED
@@ -0,0 +1,701 @@
1
+ /**
2
+ * detect.js — 「AI 味」检测实现:21 条规则的量化判定。
3
+ *
4
+ * 设计要点:
5
+ * 1. **行号可回溯**:所有匹配都跑在文件原文上(CSS 就嵌在原文里),
6
+ * 命中直接映射 file:line,报告能指到具体位置,不是只给一个分数。
7
+ * 2. **阈值按实测校准**(docs/AI感来源分析与工具箱改造方案-2026-11-07.md):
8
+ * 典型 AI 输出 72 分、受约束手写 9 分——阈值据此标定,不是拍脑袋。
9
+ * 3. **每条规则独立打分**:0 = 干净,1 = 满分失分。没有命中就没有分数,
10
+ * 避免「没写 CSS 就当 0 分」这类虚假干净。
11
+ */
12
+ import { PROFILES, RULES, gradeOf } from "./rules.js";
13
+
14
+ /** pictographic emoji(与 audit.js 同一区段口径,禁用不是「检测文案」而是「检测图标位」)。 */
15
+ const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/u;
16
+
17
+ /** 紫/靛/蓝紫家族的色相区间(HSL 度数)。 */
18
+ const PURPLE_HUES = [[245, 295]];
19
+
20
+ /** 常见中性色(不参与色相统计)。 */
21
+ const NEUTRAL_NAMES = new Set(["transparent", "currentcolor", "inherit", "none", "initial", "unset"]);
22
+
23
+ function lineOf(text, index) {
24
+ let line = 1;
25
+ for (let i = 0; i < index && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1;
26
+ return line;
27
+ }
28
+
29
+ function hit(file, index, evidence) {
30
+ return { file: file.rel, line: lineOf(file.raw, index), evidence: String(evidence).slice(0, 160) };
31
+ }
32
+
33
+ /** #rgb / #rrggbb / rgb() / rgba() → {h,s,l};无法解析返回 null。 */
34
+ function toHsl(raw) {
35
+ const value = String(raw).trim().toLowerCase();
36
+ if (NEUTRAL_NAMES.has(value)) return null;
37
+ let r;
38
+ let g;
39
+ let b;
40
+ if (value.startsWith("#")) {
41
+ let hex = value.slice(1);
42
+ if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
43
+ if (hex.length !== 6) return null;
44
+ r = parseInt(hex.slice(0, 2), 16) / 255;
45
+ g = parseInt(hex.slice(2, 4), 16) / 255;
46
+ b = parseInt(hex.slice(4, 6), 16) / 255;
47
+ if ([r, g, b].some((n) => !Number.isFinite(n))) return null;
48
+ } else {
49
+ const nums = value.match(/[\d.]+/g);
50
+ if (nums === null || nums.length < 3) return null;
51
+ r = Number(nums[0]) / 255;
52
+ g = Number(nums[1]) / 255;
53
+ b = Number(nums[2]) / 255;
54
+ }
55
+ const max = Math.max(r, g, b);
56
+ const min = Math.min(r, g, b);
57
+ const l = (max + min) / 2;
58
+ const d = max - min;
59
+ if (d === 0) return { h: 0, s: 0, l };
60
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
61
+ let h;
62
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
63
+ else if (max === g) h = ((b - r) / d + 2) / 6;
64
+ else h = ((r - g) / d + 4) / 6;
65
+ return { h: h * 360, s, l };
66
+ }
67
+
68
+ function inHueRanges(hue, ranges) {
69
+ return ranges.some(([lo, hi]) => hue >= lo && hue <= hi);
70
+ }
71
+
72
+ /** 收集文本里所有颜色字面量(含 var() 默认值之外的直接色值)。 */
73
+ function colorLiterals(text) {
74
+ const out = [];
75
+ const re = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g;
76
+ let m;
77
+ while ((m = re.exec(text)) !== null) out.push({ raw: m[0], index: m.index });
78
+ return out;
79
+ }
80
+
81
+ /** 解析 CSS 数值声明,返回 [{value, index, prop}]。 */
82
+ function numericDecls(text, props) {
83
+ const out = [];
84
+ const re = new RegExp(`(?:^|[;{\\s])(${props.join("|")})\\s*:\\s*([0-9.]+)(px|rem|em)?`, "g");
85
+ let m;
86
+ while ((m = re.exec(text)) !== null) {
87
+ const unit = m[3] ?? "";
88
+ const value = Number(m[2]) * (unit === "rem" || unit === "em" ? 16 : 1);
89
+ if (Number.isFinite(value)) out.push({ prop: m[1], value, index: m.index });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // ─────────────────────────── 15 条规则 ───────────────────────────
95
+
96
+ /**
97
+ * AI 套路文案:箭头 CTA、鸡血动词、黑话。
98
+ *
99
+ * 为什么需要:工具一直在提倡“反 AI 味”,但当时的规则里没有一条查文案。
100
+ * 实测(2026-11-07):自己做的 7 种风格 UI 里按钮写着“立即同步 →”,
101
+ * 整张表跑下来零发现——工具在提倡规范,却看不见自己身上的问题。
102
+ *
103
+ * 只收高置信度信号(宁可漏报不误报):
104
+ * → / ➜ / ↦ 出现在文本里(箭头当 CTA 后缀是典型 AI 病,也是排版噪音)
105
+ * 立即/一键/轻松/极致/无缝/解锁/赋能/助力/闭环/抓手/颗粒度
106
+ * “XX之旅”、“开启你的…” 这类开场白
107
+ */
108
+ const AI_COPY_PATTERNS = [
109
+ { re: /[→➜↦›]/u, why: "箭头当 CTA 后缀" },
110
+ // 中间允许 0–4 字修饰:“立即免费开始”是最高频的 CTA,旧写法要求“立即”紧跟动词,
111
+ // 实测漏掉了这个写法(2026-11-08,AI 首页样本里出现两次,ai-copy 零命中)。
112
+ { re: /(立即|一键)[^\n,。,;;]{0,4}(开始|体验|使用|同步|创建|生成|上手|解锁|开启|订阅)/u, why: "鸡血动词搭配" },
113
+ // 对仗句式:AI 文案的隐形指纹。单个出现可能是文风,2 次以上成规则。
114
+ { re: /不是[^\n,。!?]{2,14}[,,]\s*而是/u, why: "“不是…而是…”对仗句式" },
115
+ { re: /不仅[^\n,。!?]{2,14}[,,]\s*(更|还|也)/u, why: "“不仅…更…”递进句式" },
116
+ { re: /不只是[^\n,。!?]{2,12}[,,]\s*(更|而是)/u, why: "“不只是…更是…”递进句式" },
117
+ { re: /(轻松|极致|无缝|丝滑)(搞定|实现|体验|上手|完成)/u, why: "形容词堆砌" },
118
+ { re: /(赋能|助力|闭环|抓手|颗粒度|组合拳|护城河)/u, why: "互联网黑话" },
119
+ { re: /(开启|开始)你的.{0,8}(之旅|新篇章|新可能)/u, why: "开场白套路" }
120
+ ];
121
+
122
+ function detectAiCopy(files) {
123
+ const hits = [];
124
+ for (const file of files) {
125
+ // 只看会被渲染的文案:仍是 textSegments 的老口径,但还要排除注释与代码
126
+ const body = stripCommentsForScan(file.raw);
127
+ for (const { re, why } of AI_COPY_PATTERNS) {
128
+ for (const m of body.matchAll(new RegExp(re.source, "gu"))) {
129
+ if (hits.length >= 30) break;
130
+ const start = Math.max(0, (m.index ?? 0) - 12);
131
+ const snippet = body.slice(start, (m.index ?? 0) + m[0].length + 12).replace(/\s+/g, " ").trim();
132
+ hits.push(hit(file, m.index ?? 0, `${why}:“${m[0]}” · …${snippet}…`));
133
+ }
134
+ }
135
+ }
136
+ return { score: Math.min(1, hits.length / 4), hits, note: `AI 套路文案 ${hits.length} 处` };
137
+ }
138
+
139
+ /**
140
+ * 去掉注释(// 与 /* *\/),保留长度以维持行号。
141
+ * 目的:注释里的 emoji 是**说明文字**,不是图标位。
142
+ * 实测(2026-11-07,dsh-visualkit 自查):lib/detect.js 的注释里举了 `🚀 128K+`
143
+ * 作为「假大数带图标」的例子,结果被自己的 emoji 规则命中——纯自检测误报。
144
+ */
145
+ function stripCommentsForScan(text) {
146
+ return text
147
+ // HTML 注释也要剔:`<!-- ⚠️ 模式切换器已移除 -->` 是说明文字,不是界面图标。
148
+ // 实测(2026-11-08,Colibri):50 个 ⚠ + 7 个 👤 全在注释里,却被算成
149
+ // “emoji 当图标 40 个”(权重 14 的头号指纹),直接把 AI 味分抬到 54。
150
+ // 真正在非注释区的 emoji 只有 11 个,且大多是 ✓ ✗ 这类字符。
151
+ .replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "))
152
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
153
+ .replace(/(^|[^:])\/\/[^\n]*/g, (m, head) => head + " ".repeat(m.length - head.length));
154
+ }
155
+
156
+ function detectEmojiIcon(files) {
157
+ const hits = [];
158
+ for (const file of files) {
159
+ // 只看代码/文案本体,不看注释
160
+ const body = stripCommentsForScan(file.raw);
161
+ for (const m of body.matchAll(new RegExp(EMOJI_RE.source, "gu"))) {
162
+ if (hits.length >= 40) break;
163
+ hits.push(hit(file, m.index ?? 0, `emoji "${m[0]}"`));
164
+ }
165
+ }
166
+ return { score: Math.min(1, hits.length / 5), hits, note: `emoji ${hits.length} 个` };
167
+ }
168
+
169
+ function detectPurpleGradient(files) {
170
+ const hits = [];
171
+ for (const file of files) {
172
+ for (const m of file.raw.matchAll(/(?:linear|radial|conic)-gradient\(([^)]*)\)/gi)) {
173
+ const stops = colorLiterals(m[1]);
174
+ const purple = stops.find((stop) => {
175
+ const hsl = toHsl(stop.raw);
176
+ return hsl !== null && hsl.s > 0.2 && inHueRanges(hsl.h, PURPLE_HUES);
177
+ });
178
+ if (purple !== undefined) hits.push(hit(file, m.index ?? 0, m[0]));
179
+ }
180
+ // Tailwind 的 indigo/violet/purple 家族类名同样是这个指纹
181
+ for (const m of file.raw.matchAll(/\b(?:from|to|via|bg|text|border)-(?:indigo|violet|purple|fuchsia)-\d{2,3}\b/g)) {
182
+ hits.push(hit(file, m.index ?? 0, m[0]));
183
+ }
184
+ }
185
+ return { score: Math.min(1, hits.length / 2), hits: hits.slice(0, 20), note: `紫/蓝紫渐变 ${hits.length} 处` };
186
+ }
187
+
188
+ function detectTypeFlat(files) {
189
+ const sizes = [];
190
+ const hits = [];
191
+ for (const file of files) {
192
+ for (const decl of numericDecls(file.raw, ["font-size"])) {
193
+ sizes.push(decl.value);
194
+ hits.push(hit(file, decl.index, `font-size:${decl.value}px`));
195
+ }
196
+ // `font` 简写(`font: 14px/1.6 …`)也要算:只用简写写字号的项目不少,
197
+ // 不解析就会得到「未发现字号声明(跳过)」,与 layout_check 口径不一致。
198
+ for (const m of file.raw.matchAll(/(?:^|[;{\s])font\s*:\s*(\d*\.?\d+)(px|rem|em)/g)) {
199
+ const value = Number(m[1]) * (m[2] === "rem" || m[2] === "em" ? 16 : 1);
200
+ if (Number.isFinite(value) && !sizes.includes(value)) {
201
+ sizes.push(value);
202
+ hits.push(hit(file, m.index ?? 0, `font:${value}px`));
203
+ }
204
+ }
205
+ }
206
+ const uniq = [...new Set(sizes)].sort((a, b) => a - b);
207
+ if (uniq.length === 0) return { score: 0, hits: [], note: "未发现字号声明(跳过)" };
208
+ const ratios = uniq.slice(1).map((value, i) => value / uniq[i]);
209
+ // 层级看的是「最大字号 vs 正文」,**不是相邻两档**。
210
+ // 旧口径(相邻最大比值)有一个系统性错误:一个设计良好的 4 档梯级
211
+ // (比如 12/14/18/24,总梯级 2.0×)相邻步长只有 1.17/1.29/1.33,
212
+ // 必然被判成「无层级」——把好设计报成坏设计。
213
+ // 实测(2026-11-07,dsh-visualkit 自查):自建的 4 档 24/14=1.71× 被报「1.33× 无层级」。
214
+ // 现与 layout_check S1 统一口径:max / 正文(众数;平票靠 16)。
215
+ const counts = new Map();
216
+ for (const value of sizes) counts.set(value, (counts.get(value) ?? 0) + 1);
217
+ const byKey = [...counts.entries()].sort((a, b) => b[1] - a[1] || Math.abs(a[0] - 16) - Math.abs(b[0] - 16) || a[0] - b[0]);
218
+ const bodySize = byKey[0]?.[0] ?? uniq[0];
219
+ const maxJump = bodySize > 0 ? uniq[uniq.length - 1] / bodySize : 1;
220
+ const tiny = ratios.filter((r) => r > 1 && r < 1.18).length;
221
+ // 没跳变是致命的(0.7),档位过多/碎阶是次要问题(0.3):
222
+ // 有 1.87× 跳变的页面即使多两档标记字号,层级仍然读得出来。
223
+ const flat = maxJump < 1.6 ? 1 : 0;
224
+ const tooMany = Math.max(0, uniq.length - 5) / 5;
225
+ const noise = Math.max(tooMany, tiny / 6);
226
+ const score = Math.min(1, flat * 0.7 + noise * 0.3);
227
+ return {
228
+ // 字号问题在「整体分布」而非某一根声明,返回空 hits 避免把正"font-size:14px"误列为问题点
229
+ score,
230
+ hits: [],
231
+ note: `字号 ${uniq.length} 档 [${uniq.join(",")}],正文 ${bodySize}px,最大/正文 ${maxJump.toFixed(2)}×${flat ? "(无层级)" : "(层级成立)"}`
232
+ };
233
+ }
234
+
235
+ function detectRadiusBlob(files) {
236
+ const values = [];
237
+ const hits = [];
238
+ for (const file of files) {
239
+ for (const m of file.raw.matchAll(/border-radius\s*:\s*([^;}]+)/g)) {
240
+ const nums = [...m[1].matchAll(/([0-9.]+)(px|rem)/g)].map((x) => Number(x[1]) * (x[2] === "rem" ? 16 : 1));
241
+ if (nums.length === 0) continue;
242
+ const max = Math.max(...nums);
243
+ values.push(max);
244
+ if (max >= 12) hits.push(hit(file, m.index ?? 0, m[0]));
245
+ }
246
+ }
247
+ if (values.length === 0) return { score: 0, hits: [], note: "未发现圆角(跳过)" };
248
+ const bigRatio = values.filter((v) => v >= 12).length / values.length;
249
+ return {
250
+ score: bigRatio > 0.6 ? Math.min(1, bigRatio) : 0,
251
+ hits: hits.slice(0, 8),
252
+ note: `圆角 ${values.length} 处,≥12px 占 ${Math.round(bigRatio * 100)}%`
253
+ };
254
+ }
255
+
256
+ function detectHueSprawl(files) {
257
+ const buckets = new Map();
258
+ const hits = [];
259
+ for (const file of files) {
260
+ for (const literal of colorLiterals(file.raw)) {
261
+ const hsl = toHsl(literal.raw);
262
+ if (hsl === null || hsl.s < 0.25 || hsl.l < 0.08 || hsl.l > 0.95) continue;
263
+ const bucket = Math.round(hsl.h / 30) * 30;
264
+ if (!buckets.has(bucket)) buckets.set(bucket, hit(file, literal.index, literal.raw));
265
+ }
266
+ }
267
+ const families = buckets.size;
268
+ hits.push(...[...buckets.values()].slice(0, 10));
269
+ // ≤4 家族算正常:1 强调色 + 成/警/危三语义色本身就是四类;
270
+ // >4 说明又额外引入了无关色相,才是「调色盘失控」。
271
+ const score = families <= 4 ? 0 : Math.min(1, (families - 4) / 3);
272
+ return { score, hits, note: `彩色色相家族 ${families} 个(≤4 正常:1 强调 + 3 语义)` };
273
+ }
274
+
275
+ function detectSpacingOffGrid(files) {
276
+ const values = [];
277
+ const hits = [];
278
+ for (const file of files) {
279
+ for (const decl of numericDecls(file.raw, ["margin", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", "gap", "row-gap", "column-gap"])) {
280
+ values.push(decl.value);
281
+ // 2px 是 4px 网格的「半格」,图标与文字对齐、1px 描边补偿都用它——
282
+ // 算离网会让报告被合法的微调值淹没(实测占误报一半)。
283
+ if (decl.value > 0 && decl.value % 4 !== 0 && decl.value !== 2) hits.push(hit(file, decl.index, `${decl.prop}:${decl.value}px`));
284
+ }
285
+ }
286
+ if (values.length === 0) return { score: 0, hits: [], note: "未发现间距声明(跳过)" };
287
+ const off = values.filter((v) => v > 0 && v % 4 !== 0 && v !== 2).length;
288
+ // 按比例而非绝对数:7/32 只是局部手调,13/22 才是系统性偏离网格
289
+ return {
290
+ score: Math.min(1, off / Math.max(4, values.length * 0.6)),
291
+ hits: hits.slice(0, 8),
292
+ note: `间距 ${values.length} 处,${off} 处不在 4px 网格(${Math.round((off / values.length) * 100)}%)`
293
+ };
294
+ }
295
+
296
+ function detectShadowGlow(files) {
297
+ const hits = [];
298
+ for (const file of files) {
299
+ for (const m of file.raw.matchAll(/box-shadow\s*:\s*([^;}]+)/g)) {
300
+ const value = m[1];
301
+ // 无偏移的 0 0 Npx 是发光;彩色投影是第二个指纹
302
+ const glow = /\b0\s+0\s+[1-9]\d*px/.test(value);
303
+ const colored = colorLiterals(value).some((literal) => {
304
+ const hsl = toHsl(literal.raw);
305
+ return hsl !== null && hsl.s > 0.3;
306
+ });
307
+ if (glow || colored) hits.push(hit(file, m.index ?? 0, `box-shadow:${value.trim()}`));
308
+ }
309
+ }
310
+ return { score: Math.min(1, hits.length / 4), hits: hits.slice(0, 8), note: `发光/彩色投影 ${hits.length} 处` };
311
+ }
312
+
313
+ function detectGlassmorphism(files) {
314
+ const hits = [];
315
+ for (const file of files) {
316
+ for (const m of file.raw.matchAll(/backdrop-filter\s*:\s*[^;}]*blur/gi)) hits.push(hit(file, m.index ?? 0, m[0]));
317
+ for (const m of file.raw.matchAll(/\bbackdrop-blur(?:-\w+)?\b/g)) hits.push(hit(file, m.index ?? 0, m[0]));
318
+ }
319
+ return { score: Math.min(1, hits.length / 3), hits: hits.slice(0, 8), note: `毛玻璃 ${hits.length} 处` };
320
+ }
321
+
322
+ function detectGradientText(files) {
323
+ const hits = [];
324
+ for (const file of files) {
325
+ for (const m of file.raw.matchAll(/-webkit-background-clip\s*:\s*text|background-clip\s*:\s*text|\bbg-clip-text\b/gi)) {
326
+ hits.push(hit(file, m.index ?? 0, m[0]));
327
+ }
328
+ }
329
+ return { score: Math.min(1, hits.length / 2), hits: hits.slice(0, 6), note: `渐变文字 ${hits.length} 处` };
330
+ }
331
+
332
+ function detectNoStates(files) {
333
+ const covered = new Set();
334
+ for (const file of files) {
335
+ for (const state of ["hover", "focus", "active", "disabled"]) {
336
+ // 伪类 :state 或 HTML 的 disabled 属性都算“考虑过该状态”
337
+ const pseudo = new RegExp(`:${state}(?:-[a-z]+)?\\b`);
338
+ const attr = state === "disabled" ? /\bdisabled\b/.test(file.raw) : false;
339
+ if (pseudo.test(file.raw) || attr) covered.add(state);
340
+ }
341
+ }
342
+ const count = covered.size;
343
+ // 2 个及以上视为够用(纯静态页只有 hover 也合理),0-1 个才是问题
344
+ return {
345
+ score: count >= 3 ? 0 : count >= 2 ? 0.2 : 0.7,
346
+ hits: [],
347
+ note: `交互态覆盖 ${count}/4(${[...covered].join(",") || "无"})`
348
+ };
349
+ }
350
+
351
+ function detectFakeStat(files) {
352
+ const hits = [];
353
+ // 真实场景里大数前面常带图标/前缀(`🚀 128K+`、`· 99.9%`),
354
+ // 所以允许数字前有 ≤12 个非标签字符,否则这类最典型的假大数反而漏报。
355
+ for (const file of files) {
356
+ for (const m of file.raw.matchAll(/>\s*[^<]{0,12}(\d{2,}(?:[.,]\d+)?\s*[KkMm]\+|\d{3,}%|99\.9%)\s*</g)) {
357
+ hits.push(hit(file, m.index ?? 0, m[0].replace(/^>\s*/, "").trim()));
358
+ }
359
+ for (const m of file.raw.matchAll(/\b(lorem ipsum|coming soon|敬请期待|待补充)\b/gi)) hits.push(hit(file, m.index ?? 0, m[0]));
360
+ }
361
+ return { score: Math.min(1, hits.length / 3), hits: hits.slice(0, 8), note: `假大数/占位 ${hits.length} 处` };
362
+ }
363
+
364
+ function detectCardUniform(files) {
365
+ const classes = [];
366
+ for (const file of files) {
367
+ for (const m of file.raw.matchAll(/class="([^"]*\b(?:card|tile|item|panel)\b[^"]*)"/gi)) {
368
+ classes.push({ cls: m[1], file, index: m.index ?? 0 });
369
+ }
370
+ }
371
+ if (classes.length < 3) return { score: 0, hits: [], note: `卡片 ${classes.length} 个(≤2 不算网格)` };
372
+ // 同构判据:出现 ≥6 次且类名种类 ≤2 种
373
+ const kinds = new Set(classes.map((c) => c.cls.replace(/\s+/g, " ").trim())).size;
374
+ const uniform = classes.length >= 6 && kinds <= 2;
375
+ return {
376
+ score: uniform ? 0.8 : kinds <= 3 ? 0.35 : 0,
377
+ hits: classes.slice(0, 6).map((c) => hit(c.file, c.index, c.cls)),
378
+ note: `卡片 ${classes.length} 个 / ${kinds} 种${uniform ? "(同构网格)" : ""}`
379
+ };
380
+ }
381
+
382
+ function detectNoBreath(files) {
383
+ const values = [];
384
+ for (const file of files) {
385
+ for (const decl of numericDecls(file.raw, ["padding", "margin"])) values.push(decl.value);
386
+ }
387
+ if (values.length < 3) return { score: 0, hits: [], note: "padding/margin 太少(跳过)" };
388
+ const max = Math.max(...values);
389
+ const bigCount = values.filter((v) => v >= 20).length;
390
+ const smallCount = values.filter((v) => v <= 10).length;
391
+ // 好:页面级大留白(≥20)与控件级小留白(≤10)同时存在
392
+ const layered = bigCount > 0 && smallCount > 0;
393
+ return {
394
+ score: layered ? 0 : max < 20 ? 0.8 : 0.4,
395
+ hits: [],
396
+ note: `留白 ${values.length} 处(大 ${bigCount} / 小 ${smallCount})${layered ? ",分层明确" : ",缺分层"}`
397
+ };
398
+ }
399
+
400
+ function detectCenteredHero(files) {
401
+ const hits = [];
402
+ let centeredHeading = false;
403
+ let hasCta = false;
404
+ for (const file of files) {
405
+ // 居中必须是**标题级**的:h1/h2,或同一规则里 font-size ≥ 24px。
406
+ // 原来只要页面上出现 text-align:center 就算(页脚、按钮文案都算),
407
+ // 实测把安全软件、邮箱这类正常界面判成“居中 hero”。而且两项是「与」不是「或」——
408
+ // 光有 cta 类名或光有居中都不构成这个指纹。
409
+ for (const m of file.raw.matchAll(/([^{}]{1,80})\{([^}]{0,400})\}/g)) {
410
+ const sel = m[1].trim();
411
+ const body = m[2];
412
+ if (sel === "" || sel.startsWith("@")) continue;
413
+ if (!/text-align\s*:\s*center/i.test(body)) continue;
414
+ const size = /font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(body);
415
+ const isHeading = /\bh[12]\b/i.test(sel) || (size !== null && Number(size[1]) >= 24);
416
+ if (!isHeading) continue;
417
+ centeredHeading = true;
418
+ hits.push(hit(file, m.index ?? 0, `${sel} { text-align:center }`));
419
+ break;
420
+ }
421
+ if (/\bclass="[^"]*\b(?:hero|cta|banner)\b/i.test(file.raw)) hasCta = true;
422
+ }
423
+ return {
424
+ score: centeredHeading && hasCta ? 0.7 : 0,
425
+ hits: hits.slice(0, 3),
426
+ note: `标题级居中${centeredHeading ? "有" : "无"} / hero·CTA 容器${hasCta ? "有" : "无"}`
427
+ };
428
+ }
429
+
430
+ function detectNestBoxes(files) {
431
+ const hits = [];
432
+ for (const file of files) {
433
+ if (!/\.(css|html|vue|svelte|astro)$/i.test(file.path ?? "")) continue;
434
+ const boxed = new Set();
435
+ for (const m of file.raw.matchAll(/([^{}]{1,80})\{[^}]*border-radius\s*:/g)) {
436
+ const sel = m[1].trim().replace(/\s+/g, " ");
437
+ if (sel !== "" && !sel.startsWith("@")) boxed.add(sel);
438
+ }
439
+ const isChild = (inner, outer) => inner !== outer && (inner.startsWith(outer + " ") || inner.startsWith(outer + ">"));
440
+ for (const outer of boxed) {
441
+ for (const mid of boxed) {
442
+ if (!isChild(mid, outer)) continue;
443
+ // 只认「三层以上」:框 ▸ 框 ▸ 框。两层是正常的“卡片 + 内部区块”,
444
+ // 算两层会把「卡片里放一个搜索框」这种正常结构判成套娃(实测误报)。
445
+ const third = [...boxed].find((deep) => isChild(deep, mid));
446
+ if (third === undefined) continue;
447
+ hits.push(hit(file, file.raw.indexOf(third), `${outer} ▸ ${mid} ▸ ${third}`));
448
+ }
449
+ }
450
+ }
451
+ const score = hits.length < 3 ? 0 : Math.min(1, hits.length / 6);
452
+ return { score, hits: hits.slice(0, 10), note: `框里套框 ${hits.length} 处` };
453
+ }
454
+
455
+ function detectDividerSprawl(files) {
456
+ const hits = [];
457
+ for (const file of files) {
458
+ if (!/\.(css|html|vue|svelte|astro)$/i.test(file.path ?? "")) continue;
459
+ for (const m of file.raw.matchAll(/border-(?:top|bottom)\s*:\s*1px/gi)) hits.push(hit(file, m.index ?? 0, m[0]));
460
+ for (const m of file.raw.matchAll(/<hr\b/gi)) hits.push(hit(file, m.index ?? 0, "<hr>"));
461
+ }
462
+ // 表格/列表天然要用分割线(实测:邮件列表 7 条、文件表 4 条都是正常设计),
463
+ // 所以 <8 处一律不计分;真正“泛滥”的是每个区块之间都画一条。
464
+ const score = hits.length < 8 ? 0 : Math.min(1, hits.length / 16);
465
+ return { score, hits: hits.slice(0, 12), note: `分割线 ${hits.length} 处` };
466
+ }
467
+
468
+ function detectBigBlackTight(files) {
469
+ const hits = [];
470
+ for (const file of files) {
471
+ if (!/\.(css|html|vue|svelte|astro)$/i.test(file.path ?? "")) continue;
472
+ for (const m of file.raw.matchAll(/([^{}]{1,80})\{([^}]{0,600})\}/g)) {
473
+ const sel = m[1].trim();
474
+ if (sel === "" || sel.startsWith("@")) continue;
475
+ const body = m[2];
476
+ const size = /font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(body);
477
+ const weight = /font-weight\s*:\s*(\d{3}|bold)/i.exec(body);
478
+ const lh = /line-height\s*:\s*(\d+(?:\.\d+)?)(?!px)/i.exec(body);
479
+ if (size === null || weight === null || lh === null) continue;
480
+ const px = Number(size[1]);
481
+ const w = weight[1].toLowerCase() === "bold" ? 700 : Number(weight[1]);
482
+ const ratio = Number(lh[1]);
483
+ if (px >= 30 && w >= 700 && ratio <= 1.25) hits.push(hit(file, m.index ?? 0, `${sel} ${px}px / ${w} / lh ${ratio}`));
484
+ }
485
+ }
486
+ return { score: Math.min(1, hits.length / 2), hits: hits.slice(0, 10), note: `又大又黑又挤 ${hits.length} 处` };
487
+ }
488
+
489
+ function detectDeadButton(files) {
490
+ // 【按 id/class 在 JS 里绑定】是最常见的写法(Electron/原生页尤其如此):
491
+ // `document.getElementById('searchBtn').addEventListener('click', …)`。
492
+ // 只看标签上有没有 data-action/onclick 会把这些全判成“死按钮”——
493
+ // 实测(2026-11-08,Colibri):报 47 个,实际上每一个都在 js/*.js 里绑了监听。
494
+ // 所以先把「JS 真的绑过的 id / class」收集起来,命中就不算死。
495
+ const boundIds = new Set();
496
+ const boundClasses = new Set();
497
+ for (const file of files) {
498
+ const text = stripCommentsForScan(file.raw);
499
+ for (const m of text.matchAll(/getElementById\(\s*["']([\w-]+)["']/g)) boundIds.add(m[1]);
500
+ for (const m of text.matchAll(/querySelector(?:All)?\(\s*["'`]([^"'`]*)["'`]/g)) {
501
+ for (const id of m[1].matchAll(/#([\w-]+)/g)) boundIds.add(id[1]);
502
+ for (const cls of m[1].matchAll(/\.([\w-]+)/g)) boundClasses.add(cls[1]);
503
+ }
504
+ }
505
+ const isBound = (attrs) => {
506
+ const id = /\bid\s*=\s*["']([\w-]+)["']/i.exec(attrs);
507
+ if (id !== null && boundIds.has(id[1])) return true;
508
+ const cls = /\bclass\s*=\s*["']([^"']*)["']/i.exec(attrs);
509
+ if (cls !== null && cls[1].split(/\s+/).some((c) => c !== "" && boundClasses.has(c))) return true;
510
+ return false;
511
+ };
512
+ const hits = [];
513
+ for (const file of files) {
514
+ if (!/\.(html|vue|svelte|astro|jsx|tsx)$/i.test(file.path ?? "")) continue;
515
+ const body = stripCommentsForScan(file.raw);
516
+ for (const m of body.matchAll(/<button\b([^>]*)>/gi)) {
517
+ const attrs = m[1];
518
+ if (/\btype\s*=\s*["']?submit/i.test(attrs)) continue;
519
+ if (/\bon(?:click|mousedown|pointerdown)\s*=/i.test(attrs)) continue;
520
+ if (/@click|v-on:click|onClick/i.test(attrs)) continue;
521
+ if (/\bdata-(?:action|href|to)\b/i.test(attrs)) continue;
522
+ if (/\bdisabled\b/i.test(attrs)) continue;
523
+ if (isBound(attrs)) continue;
524
+ hits.push(hit(file, m.index ?? 0, `<button${attrs.slice(0, 40)}>`));
525
+ }
526
+ }
527
+ // 单个死按钮可能只是静态演示页;**两个以上**才说明「控件摆出来了、行为没接」是系统性的。
528
+ // (实测:干净样本里恰好有一个 <button>,按 >=1 计分会把整份干净样判成 5 分。)
529
+ const score = hits.length < 2 ? 0 : Math.min(1, hits.length / 3);
530
+ return { score, hits: hits.slice(0, 10), note: `无行为的按钮 ${hits.length} 个` };
531
+ }
532
+
533
+ /**
534
+ * 图标色块一律:每个卡片都套一个同尺寸、同圆角、同背景的图标块。
535
+ *
536
+ * 实测来源(2026-11-08):写一个当代 AI 首页样本(三特性卡 + 四段小标签 + 对仗文案),
537
+ * 最显眼的 AI 感就是三个 48px 同款渐变圆角块——而当时 19 条规则一条都没抓到,
538
+ * 总分只有 45,和真实产品参照稿(11–24)没拉开到应有的距离。
539
+ *
540
+ * 判据(三项必须同时成立):
541
+ * ① HTML 里同类图标块 ≥ 3 个;
542
+ * ② CSS 里该类的宽高落在 32–64px 且圆角 ≥ 12px(尺寸统一才叫“一律”);
543
+ * ③ 背景是渐变。
544
+ * 只满足 ①②(背景是纯色块)给 35:仍然是模板卡片长相,但纯色块在真实产品里也常见,
545
+ * 不足以单独定性。
546
+ * —— 宁可不报,不报错的:三项取交集,才不会把工具栏方块按钮、头像沦为目标。
547
+ */
548
+ function detectIconTileUniform(files) {
549
+ const TILE_TOKEN = /(?:^|[-_\s])(?:icon|ico|feature|card)[-_]?(?:tile|box|wrap|badge|circle|square|bubble)(?:$|[-_\s])|(?:^|[-_\s])(?:tile|badge)[-_]?(?:icon|ico)(?:$|[-_\s])/i;
550
+ // ① 用法计数(只看 class 属性,不看注释)
551
+ const usage = [];
552
+ for (const file of files) {
553
+ const body = stripCommentsForScan(file.raw);
554
+ for (const m of body.matchAll(/class="([^"]*)"/gi)) {
555
+ for (const token of m[1].split(/\s+/)) {
556
+ if (token !== "" && TILE_TOKEN.test(token)) usage.push({ token, file, index: (m.index ?? 0) + 6 });
557
+ }
558
+ }
559
+ }
560
+ if (usage.length < 3) return { score: 0, hits: [], note: `图标块 ${usage.length} 个(≤2 不算一律)` };
561
+ // ② 尺寸/圆角/背景:找这些类名的 CSS 规则
562
+ const px = (v) => {
563
+ const m = /(-?\d+(?:\.\d+)?)px/.exec(String(v));
564
+ return m === null ? null : Number.parseFloat(m[1]);
565
+ };
566
+ let sized = 0;
567
+ let gradient = 0;
568
+ for (const file of files) {
569
+ if (/\.(?:css|scss|less|jsx?|tsx?|vue|svelte)$/i.test(file.path ?? file.rel ?? "") === false && /\.html?$/i.test(file.path ?? file.rel ?? "") === false) continue;
570
+ for (const rule of file.raw.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
571
+ // 选择器里的 `.` 必须当分隔符:`.icon-tile` 直接跑正则得不到词边界(`.` 不在 [-_\s] 里),
572
+ // 实测因此把整条规则漏掉,检测器静默永不出手。
573
+ if (TILE_TOKEN.test(rule[1].replace(/\./g, " ")) === false) continue;
574
+ const decls = rule[2];
575
+ const w = /(?:^|[;\s])(?:width|inline-size)\s*:\s*([^;]+)/.exec(decls);
576
+ const h = /(?:^|[;\s])(?:height|block-size)\s*:\s*([^;]+)/.exec(decls);
577
+ const r = /(?:^|[;\s])border-radius\s*:\s*([^;]+)/.exec(decls);
578
+ const wv = w === null ? null : px(w[1]);
579
+ const hv = h === null ? null : px(h[1]);
580
+ const rv = r === null ? null : px(r[1]);
581
+ if (wv === null || hv === null || wv < 32 || wv > 64 || Math.abs(wv - hv) > 2) continue;
582
+ if (rv === null || rv < 12) continue;
583
+ sized += 1;
584
+ if (/(?:linear|radial|conic)-gradient\(/i.test(decls)) gradient += 1;
585
+ }
586
+ }
587
+ const score = gradient > 0 ? 0.8 : sized > 0 ? 0.35 : 0;
588
+ return {
589
+ score,
590
+ hits: score === 0 ? [] : usage.slice(0, 6).map((u) => hit(u.file, u.index, `${u.token}(${gradient > 0 ? "同款渐变块" : "同尺寸块"})`)),
591
+ note: `图标块 ${usage.length} 个 / 尺寸统一 ${sized} 组${gradient > 0 ? "(含渐变)" : ""}`
592
+ };
593
+ }
594
+
595
+ /**
596
+ * 小标签逐段开场:每个区块都用一个小号大字距标签(eyebrow/kicker)开场。
597
+ *
598
+ * 保留 0–1 个是**正常的编辑手法**(说明栏目归属),不算 AI 味;
599
+ * 四段以上、段段都来一个,才是模板句式。阈值定在 4,就是给正常版面留余地。
600
+ *
601
+ * 已知局限(有意接受):靠类名识别,若项目用 `.label`/`.tag` 这类通用名会漏报。
602
+ * 宁可漏报:按字号+字距反推需要 DOM,而静态扫描看不到渲染后的样式归属。
603
+ */
604
+ const EYEBROW_TOKEN = /(?:^|[-_\s])(?:eyebrow|kicker|overline|suptitle|pretitle|section[-_]?label|label[-_]?caps)(?:$|[-_\s])/i;
605
+
606
+ function detectEyebrowSpam(files) {
607
+ const hits = [];
608
+ for (const file of files) {
609
+ const body = stripCommentsForScan(file.raw);
610
+ for (const m of body.matchAll(/class="([^"]*)"/gi)) {
611
+ for (const token of m[1].split(/\s+/)) {
612
+ if (token !== "" && EYEBROW_TOKEN.test(token)) hits.push(hit(file, (m.index ?? 0) + 6, token));
613
+ }
614
+ }
615
+ }
616
+ // 0–3 个:正常编辑手法,不报
617
+ const score = hits.length < 4 ? 0 : Math.min(1, (hits.length - 2) / 4);
618
+ return { score, hits: score === 0 ? [] : hits.slice(0, 6), note: `小标签 ${hits.length} 个` };
619
+ }
620
+
621
+ const DETECTORS = {
622
+ "emoji-icon": detectEmojiIcon,
623
+ "purple-gradient": detectPurpleGradient,
624
+ "type-flat": detectTypeFlat,
625
+ "radius-blob": detectRadiusBlob,
626
+ "hue-sprawl": detectHueSprawl,
627
+ "spacing-offgrid": detectSpacingOffGrid,
628
+ "shadow-glow": detectShadowGlow,
629
+ glassmorphism: detectGlassmorphism,
630
+ "gradient-text": detectGradientText,
631
+ "no-states": detectNoStates,
632
+ "fake-stat": detectFakeStat,
633
+ "ai-copy": detectAiCopy,
634
+ "card-uniform": detectCardUniform,
635
+ "no-breath": detectNoBreath,
636
+ "centered-hero": detectCenteredHero,
637
+ "nest-boxes": detectNestBoxes,
638
+ "divider-sprawl": detectDividerSprawl,
639
+ "big-black-tight": detectBigBlackTight,
640
+ "dead-button": detectDeadButton,
641
+ "icon-tile-uniform": detectIconTileUniform,
642
+ "eyebrow-spam": detectEyebrowSpam
643
+ };
644
+
645
+ /**
646
+ * 跑全部规则:返回总分 + 每条规则的得分与命中位置。
647
+ *
648
+ * 分层计分(见 rules.js 的 scope / PROFILES):
649
+ * counted 该规则在当前 profile 下参与计分 → 命中走 findings(要改)
650
+ * not counted 风格层在当前 profile 被降为 0 权重 → 命中走 advisories(提示不改)
651
+ * 这样「structure」档下品牌风格不会被判成缺陷,但它仍然被看见。
652
+ *
653
+ * @param {{path:string, rel:string, raw:string}[]} files
654
+ * @param {{ignore?:string[], profile?:string}} opts
655
+ * @returns {{score:number, grade:string, profile:string, metrics:object[], findings:object[], advisories:object[]}}
656
+ */
657
+ function runRules(files, opts = {}) {
658
+ const profileName = PROFILES[opts.profile] === undefined ? "balanced" : opts.profile;
659
+ const factors = PROFILES[profileName];
660
+ const ignore = new Set(opts.ignore ?? []);
661
+ const metrics = [];
662
+ const findings = [];
663
+ const advisories = [];
664
+ let weighted = 0;
665
+ let weightSum = 0;
666
+ for (const rule of RULES) {
667
+ if (ignore.has(rule.id)) continue;
668
+ const result = DETECTORS[rule.id](files);
669
+ const factor = factors[rule.scope] ?? 1;
670
+ const counted = factor > 0;
671
+ metrics.push({
672
+ id: rule.id,
673
+ label: rule.label,
674
+ scope: rule.scope,
675
+ weight: rule.weight,
676
+ counted,
677
+ fingerprint: rule.fingerprint === true,
678
+ score: Math.round(result.score * 100),
679
+ note: result.note
680
+ });
681
+ if (counted) {
682
+ weighted += result.score * rule.weight * factor;
683
+ weightSum += rule.weight * factor;
684
+ }
685
+ if (result.score > 0) {
686
+ const target = counted ? findings : advisories;
687
+ for (const h of result.hits) {
688
+ target.push({ rule: rule.id, label: rule.label, scope: rule.scope, fix: rule.fix, ...h });
689
+ }
690
+ // 统计型规则(字号/交互态/留白)没有具体位置,但也要能在 advisories 里被看到
691
+ if (result.hits.length === 0 && !counted) {
692
+ advisories.push({ rule: rule.id, label: rule.label, scope: rule.scope, fix: rule.fix, note: result.note });
693
+ }
694
+ }
695
+ }
696
+ metrics.sort((a, b) => b.score * b.weight * (b.counted ? 1 : 0) - a.score * a.weight * (a.counted ? 1 : 0));
697
+ const score = weightSum === 0 ? 0 : Math.round((weighted / weightSum) * 100);
698
+ return { score, grade: gradeOf(score), profile: profileName, metrics, findings, advisories };
699
+ }
700
+
701
+ export { runRules, toHsl, lineOf };