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
@@ -0,0 +1,575 @@
1
+ /**
2
+ * layouts/rules-contrast.js — 文字对比度检测(WCAG 2.x + APCA),layout_check 的对比度规则库。
3
+ *
4
+ * 为什么两套都要:
5
+ * WCAG 2.x 的 contrast ratio 是行业合规底线(AA 4.5:1 / AAA 7:1),但它只看亮度比,
6
+ * 对深色底上的浅字、细体小字都过于宽容;APCA(WCAG 3 候选算法)按极性与字号加权,
7
+ * 更贴近实际观感。两者互相补位:WCAG 管「合不合规」,APCA 管「好不好读」。
8
+ *
9
+ * 设计与边界(都是刻意的,改之前先看这里):
10
+ * 1. **不 import ../svg.js**:那边的 hexOf() 只认 #rgb/#rrggbb,且 import 会形成
11
+ * layouts → svg → layouts 的循环依赖。本模块自带完整解析器,只依赖 common.js 的 finding()。
12
+ * 2. 解析不出来(currentColor / inherit / var(--x) / 空串…)**不报错也不瞎猜**,
13
+ * 产出一条 info 说明「跳过」,让调用方知道这条没测。
14
+ * 3. 前景半透明(alpha < 1)时按「前景叠在给定背景上」合成后再算比例(背景若无 alpha
15
+ * 则先假定它在白色画布上)。半透明文字的真实对比度还取决于下面的东西,所以另外
16
+ * 出一条 contrast-low-alpha 提醒这段固定会被高估。
17
+ * 4. 建议色是确定性的:把前景朝黑或朝白按 1% 步长推,取第一个达标的,同输入同输出。
18
+ *
19
+ * `transparent` 视为「未指定前景色」→ 返回 null(和 currentColor 一样跳过)。
20
+ */
21
+
22
+ import { finding } from "./common.js";
23
+
24
+ /* ------------------------------ 1. 颜色解析 ------------------------------ */
25
+
26
+ /** 常用命名色(CSS 常见子集,57 个;够覆盖 normal.css 手写场景,不做全量 148 色)。 */
27
+ const NAMED = {
28
+ aliceblue: [240, 248, 255],
29
+ aqua: [0, 255, 255],
30
+ beige: [245, 245, 220],
31
+ black: [0, 0, 0],
32
+ blue: [0, 0, 255],
33
+ brown: [165, 42, 42],
34
+ burlywood: [222, 184, 135],
35
+ cadetblue: [95, 158, 160],
36
+ chocolate: [210, 105, 30],
37
+ coral: [255, 127, 80],
38
+ cornflowerblue: [100, 149, 237],
39
+ crimson: [220, 20, 60],
40
+ cyan: [0, 255, 255],
41
+ darkblue: [0, 0, 139],
42
+ darkgray: [169, 169, 169],
43
+ darkgreen: [0, 100, 0],
44
+ darkgrey: [169, 169, 169],
45
+ darkorange: [255, 140, 0],
46
+ darkslategray: [47, 79, 79],
47
+ deepskyblue: [0, 191, 255],
48
+ dimgray: [105, 105, 105],
49
+ dimgrey: [105, 105, 105],
50
+ dodgerblue: [30, 144, 255],
51
+ firebrick: [178, 34, 34],
52
+ forestgreen: [34, 139, 34],
53
+ fuchsia: [255, 0, 255],
54
+ gainsboro: [220, 220, 220],
55
+ gold: [255, 215, 0],
56
+ goldenrod: [218, 165, 32],
57
+ gray: [128, 128, 128],
58
+ green: [0, 128, 0],
59
+ greenyellow: [173, 255, 47],
60
+ grey: [128, 128, 128],
61
+ hotpink: [255, 105, 180],
62
+ indianred: [205, 92, 92],
63
+ indigo: [75, 0, 130],
64
+ ivory: [255, 255, 240],
65
+ khaki: [240, 230, 140],
66
+ lavender: [230, 230, 250],
67
+ lightblue: [173, 216, 230],
68
+ lightgray: [211, 211, 211],
69
+ lightgreen: [144, 238, 144],
70
+ lightgrey: [211, 211, 211],
71
+ lightpink: [255, 182, 193],
72
+ lightslategray: [119, 136, 153],
73
+ lime: [0, 255, 0],
74
+ limegreen: [50, 205, 50],
75
+ magenta: [255, 0, 255],
76
+ maroon: [128, 0, 0],
77
+ mediumpurple: [147, 112, 219],
78
+ midnightblue: [25, 25, 112],
79
+ navy: [0, 0, 128],
80
+ olive: [128, 128, 0],
81
+ orange: [255, 165, 0],
82
+ orangered: [255, 69, 0],
83
+ orchid: [218, 112, 214],
84
+ plum: [221, 160, 221],
85
+ purple: [128, 0, 128],
86
+ rebeccapurple: [102, 51, 153],
87
+ red: [255, 0, 0],
88
+ royalblue: [65, 105, 225],
89
+ salmon: [250, 128, 114],
90
+ seagreen: [46, 139, 87],
91
+ sienna: [160, 82, 45],
92
+ silver: [192, 192, 192],
93
+ skyblue: [135, 206, 235],
94
+ slateblue: [106, 90, 205],
95
+ slategray: [112, 128, 144],
96
+ springgreen: [0, 255, 127],
97
+ steelblue: [70, 130, 180],
98
+ tan: [210, 180, 140],
99
+ teal: [0, 128, 128],
100
+ tomato: [255, 99, 71],
101
+ turquoise: [64, 224, 208],
102
+ violet: [238, 130, 238],
103
+ wheat: [245, 222, 179],
104
+ white: [255, 255, 255],
105
+ whitesmoke: [245, 245, 245],
106
+ yellow: [255, 255, 0],
107
+ yellowgreen: [154, 205, 50]
108
+ };
109
+
110
+ /** 命名色表(导出给测试与调用方做「是不是命名色」判定;不要在别处另抄一份)。 */
111
+ export const NAMED_COLORS = Object.keys(NAMED);
112
+
113
+ const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n);
114
+
115
+ /** 0–255 通道值(数字或百分比)。 */
116
+ function channel(token) {
117
+ const text = String(token).trim();
118
+ const pct = /^([+-]?\d*\.?\d+)%$/.exec(text);
119
+ if (pct !== null) return clamp((Number(pct[1]) / 100) * 255, 0, 255);
120
+ const n = Number(text);
121
+ return Number.isFinite(n) ? clamp(n, 0, 255) : null;
122
+ }
123
+
124
+ /** 0–1 alpha(数字或百分比)。 */
125
+ function alpha(token) {
126
+ if (token === undefined || token === null) return 1;
127
+ const text = String(token).trim();
128
+ const pct = /^([+-]?\d*\.?\d+)%$/.exec(text);
129
+ if (pct !== null) return clamp(Number(pct[1]) / 100, 0, 1);
130
+ const n = Number(text);
131
+ return Number.isFinite(n) ? clamp(n, 0, 1) : null;
132
+ }
133
+
134
+ /** 把 `rgb(...)` 内层拆成主参数 + 可选 alpha(支持逗号和 CSS4 空格/斜杠两种写法)。 */
135
+ function splitArgs(body) {
136
+ const parts = body.split("/");
137
+ const main = parts.shift().trim();
138
+ const args = main === "" ? [] : main.split(/[\s,]+/).filter((s) => s !== "");
139
+ const rest = parts.join("/").trim();
140
+ return { args, alphaToken: rest === "" ? undefined : rest };
141
+ }
142
+
143
+ /** hsl 色相:无单位 = 角度,也支持 deg / turn / rad / grad。 */
144
+ function hue(token) {
145
+ const text = String(token).trim().toLowerCase();
146
+ const m = /^([+-]?\d*\.?\d+)(deg|turn|rad|grad)?$/.exec(text);
147
+ if (m === null) return null;
148
+ const n = Number(m[1]);
149
+ const unit = m[2] ?? "deg";
150
+ const deg = unit === "turn" ? n * 360 : unit === "rad" ? (n * 180) / Math.PI : unit === "grad" ? n * 0.9 : n;
151
+ return Number.isFinite(deg) ? deg : null;
152
+ }
153
+
154
+ /** hsl → rgb(标准 CSS 算法,h 归一化后按 6 段线性插值)。 */
155
+ function hslToRgb(h, s, l) {
156
+ const hueDeg = ((h % 360) + 360) % 360;
157
+ const sat = clamp(s, 0, 1);
158
+ const lit = clamp(l, 0, 1);
159
+ const c = (1 - Math.abs(2 * lit - 1)) * sat;
160
+ const hp = hueDeg / 60;
161
+ const x = c * (1 - Math.abs((hp % 2) - 1));
162
+ const m = lit - c / 2;
163
+ const seg = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.min(5, Math.floor(hp))];
164
+ return { r: Math.round((seg[0] + m) * 255), g: Math.round((seg[1] + m) * 255), b: Math.round((seg[2] + m) * 255) };
165
+ }
166
+
167
+ /**
168
+ * CSS 颜色值 → {r,g,b,a};解析失败返回 null(调用方跳过,不报错)。
169
+ * 支持 #rgb #rgba #rrggbb #rrggbbaa / rgb() rgba() / hsl() hsla() / 命名色。
170
+ * @param {unknown} value
171
+ * @returns {{r:number,g:number,b:number,a:number}|null}
172
+ */
173
+ export function parseColor(value) {
174
+ if (typeof value !== "string") return null;
175
+ const text = value.trim().toLowerCase();
176
+ if (text === "") return null;
177
+
178
+ // #rgb / #rgba / #rrggbb / #rrggbbaa
179
+ const hex = /^#([0-9a-f]+)$/.exec(text);
180
+ if (hex !== null) {
181
+ const d = hex[1];
182
+ if (d.length === 3 || d.length === 4) {
183
+ const ch = (i) => parseInt(d[i] + d[i], 16);
184
+ const a = d.length === 4 ? ch(3) / 255 : 1;
185
+ return { r: ch(0), g: ch(1), b: ch(2), a };
186
+ }
187
+ if (d.length === 6 || d.length === 8) {
188
+ const ch = (i) => parseInt(d.slice(i * 2, i * 2 + 2), 16);
189
+ const a = d.length === 8 ? ch(3) / 255 : 1;
190
+ return { r: ch(0), g: ch(1), b: ch(2), a };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ const fn = /^(rgba?|hsla?)\(([^()]*)\)$/.exec(text);
196
+ if (fn !== null) {
197
+ // 两种合法写法要同时吃下:rgba(0,0,0,0.5)(逗号 4 参)与 rgb(0 0 0 / 50%)(斜杠)
198
+ let { args, alphaToken } = splitArgs(fn[2]);
199
+ if (args.length === 4 && alphaToken === undefined) {
200
+ alphaToken = args[3];
201
+ args = args.slice(0, 3);
202
+ }
203
+ if (args.length !== 3) return null;
204
+ const a = alpha(alphaToken);
205
+ if (a === null) return null;
206
+ if (fn[1].startsWith("rgb")) {
207
+ const [r, g, b] = args.map(channel);
208
+ if (r === null || g === null || b === null) return null;
209
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b), a };
210
+ }
211
+ const h = hue(args[0]);
212
+ if (h === null) return null;
213
+ const pct = (token) => {
214
+ const m = /^([+-]?\d*\.?\d+)%$/.exec(String(token).trim());
215
+ return m === null ? null : Number(m[1]) / 100;
216
+ };
217
+ const s = pct(args[1]);
218
+ const l = pct(args[2]);
219
+ if (s === null || l === null) return null;
220
+ return { ...hslToRgb(h, s, l), a };
221
+ }
222
+
223
+ const named = NAMED[text];
224
+ if (named !== undefined) return { r: named[0], g: named[1], b: named[2], a: 1 };
225
+ return null;
226
+ }
227
+
228
+ /** 内部:接受 {r,g,b} 对象或 CSS 字符串;解析不出来直接抛(调用前必须已校验)。 */
229
+ function asRgb(color, label) {
230
+ if (color !== null && typeof color === "object" && Number.isFinite(color.r)) return color;
231
+ const parsed = parseColor(color);
232
+ if (parsed === null) throw new TypeError(`${label} 不是可解析的颜色:${String(color)}`);
233
+ return parsed;
234
+ }
235
+
236
+ /** #RRGGBB(大写,忽略 alpha)。 */
237
+ function toHex({ r, g, b }) {
238
+ const h = (v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0").toUpperCase();
239
+ return `#${h(r)}${h(g)}${h(b)}`;
240
+ }
241
+
242
+ /** 前景叠在背景上(背景自身半透明时先假定它压在白纸上)。 */
243
+ function over(fg, bg) {
244
+ const bgOpaque = bg.a < 1 ? blend(bg, { r: 255, g: 255, b: 255, a: 1 }) : bg;
245
+ if (fg.a >= 1) return { r: fg.r, g: fg.g, b: fg.b, a: 1 };
246
+ return blend(fg, bgOpaque);
247
+ }
248
+
249
+ function blend(fg, bg) {
250
+ const a = fg.a;
251
+ return {
252
+ r: Math.round(fg.r * a + bg.r * (1 - a)),
253
+ g: Math.round(fg.g * a + bg.g * (1 - a)),
254
+ b: Math.round(fg.b * a + bg.b * (1 - a)),
255
+ a: 1
256
+ };
257
+ }
258
+
259
+ /* --------------------------- 2. WCAG 2.x 对比度 --------------------------- */
260
+
261
+ /** WCAG 2.x 相对亮度(sRGB 线性化后的 Rec.709 加权)。 */
262
+ export function relativeLuminance(rgb) {
263
+ const c = asRgb(rgb, "rgb");
264
+ const lin = (v) => {
265
+ const s = clamp(v, 0, 255) / 255;
266
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
267
+ };
268
+ return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
269
+ }
270
+
271
+ /** WCAG 2.x 对比度(1–21,1 = 同色)。参数可传 {r,g,b} 或 CSS 字符串。 */
272
+ export function contrastRatio(fg, bg) {
273
+ const a = relativeLuminance(asRgb(fg, "fg"));
274
+ const b = relativeLuminance(asRgb(bg, "bg"));
275
+ const hi = Math.max(a, b);
276
+ const lo = Math.min(a, b);
277
+ return (hi + 0.05) / (lo + 0.05);
278
+ }
279
+
280
+ /* ------------------------------- 3. APCA ------------------------------- */
281
+
282
+ /**
283
+ * APCA-W3 0.0.98G 参考实现的常量。
284
+ * 来源:APCA/W3C 参考实现 sa98g.js(W3 0.0.98G-4g)。注意它的 Y 用**纯 2.4 幂**
285
+ * 而不是 sRGB 的分段线性化,所以不能复用 relativeLuminance()。
286
+ */
287
+ const APCA = {
288
+ mainTRC: 2.4,
289
+ Rco: 0.2126729,
290
+ Gco: 0.7151522,
291
+ Bco: 0.072175,
292
+ normBG: 0.56,
293
+ normTXT: 0.57,
294
+ revTXT: 0.62,
295
+ revBG: 0.65,
296
+ blkThrs: 0.022,
297
+ blkClmp: 1.414,
298
+ scaleBoW: 1.14,
299
+ scaleWoB: 1.14,
300
+ loBoWoffset: 0.027,
301
+ loWoBoffset: 0.027,
302
+ deltaYmin: 0.0005,
303
+ loClip: 0.1
304
+ };
305
+
306
+ /** APCA 专用亮度 Y(含近黑软钳制,避免极暗色被算成 0 导致比例爆掉)。 */
307
+ function apcaY(rgb) {
308
+ const ch = (v) => Math.pow(clamp(v, 0, 255) / 255, APCA.mainTRC);
309
+ let y = APCA.Rco * ch(rgb.r) + APCA.Gco * ch(rgb.g) + APCA.Bco * ch(rgb.b);
310
+ if (y < APCA.blkThrs) y += Math.pow(APCA.blkThrs - y, APCA.blkClmp);
311
+ return y;
312
+ }
313
+
314
+ /** 小字折价系数(近似,见 apcaLc 注释)。 */
315
+ const SMALL_TEXT_WEIGHT = 1.14;
316
+
317
+ /**
318
+ * APCA 官方参考值(自测对照用,不是我算的结论):APCA-W3 0.0.98G 公布的极值用例
319
+ * 与查询表里的 #888 on #fff。容差 ±3(不同小版本在 loClip/offset 上有 0.1~1 的抖动)。
320
+ */
321
+ export const APCA_OFFICIAL = {
322
+ blackOnWhite: 106.04,
323
+ whiteOnBlack: -107.88,
324
+ gray888OnWhite: 63.1
325
+ };
326
+
327
+ function sizeWeight(fontSizePx, bold) {
328
+ const size = Number(fontSizePx);
329
+ const fs = Number.isFinite(size) && size > 0 ? size : 16;
330
+ // 粗体按 1.25× 等效字号(APCA 的 SA98G 查表里 700 权重约等于大一档字号)
331
+ const eff = bold ? fs * 1.25 : fs;
332
+ return eff < 16 ? Math.pow(SMALL_TEXT_WEIGHT, (16 - eff) / 16) : 1;
333
+ }
334
+
335
+ /**
336
+ * APCA Lc(-108..106;正 = 深色字浅色底,负 = 浅色字深色底,绝对值越大越易读)。
337
+ *
338
+ * **字号加权是近似**:APCA 官方不把字号算进 Lc,而是用 SA98G 查表给「该字号需要多少 Lc」
339
+ * 的门槛。为了让本模块能「给定字号直接判一条色对」,这里把 <16px 的小字按
340
+ * weight = 1.14^((16-等效字号)/16) 折价(最多 14%),等效于小字更难达标的同一个效果。
341
+ * 因此**核对官方 Lc 数值时请传 fontSizePx ≥ 16**(此时 weight 恰好为 1,输出与官方一致)。
342
+ *
343
+ * @param {{r:number,g:number,b:number}|string} fg
344
+ * @param {{r:number,g:number,b:number}|string} bg
345
+ * @param {number} [fontSizePx=16]
346
+ * @param {boolean} [bold=false]
347
+ * @returns {number}
348
+ */
349
+ export function apcaLc(fg, bg, fontSizePx = 16, bold = false) {
350
+ const yTxt = apcaY(asRgb(fg, "fg"));
351
+ const yBg = apcaY(asRgb(bg, "bg"));
352
+ let lc;
353
+ if (Math.abs(yBg - yTxt) < APCA.deltaYmin) return 0;
354
+ if (yBg > yTxt) {
355
+ const sapc = (Math.pow(yBg, APCA.normBG) - Math.pow(yTxt, APCA.normTXT)) * APCA.scaleBoW;
356
+ lc = sapc < APCA.loClip ? 0 : (sapc - APCA.loBoWoffset) * 100;
357
+ } else {
358
+ const sapc = (Math.pow(yBg, APCA.revBG) - Math.pow(yTxt, APCA.revTXT)) * APCA.scaleWoB;
359
+ lc = sapc > -APCA.loClip ? 0 : (sapc + APCA.loWoBoffset) * 100;
360
+ }
361
+ const weight = sizeWeight(fontSizePx, bold);
362
+ const value = weight === 1 ? lc : lc / weight;
363
+ return Number(value.toFixed(2));
364
+ }
365
+
366
+ /* ------------------------------ 4. 阈值 ------------------------------ */
367
+
368
+ const AA_BODY = 4.5;
369
+ const AA_LARGE = 3;
370
+ const AAA_BODY = 7;
371
+ const AAA_LARGE = 4.5;
372
+ const APCA_BODY = 60;
373
+ const APCA_LARGE = 45;
374
+ const LOW_ALPHA = 0.85;
375
+
376
+ /**
377
+ * 是否「大字」,按 WCAG 原文:非粗体 ≥ 24px(18pt),或粗体 ≥ 18.66px(14pt)。
378
+ * 未加粗的 18.66px 只是 14pt 的**常规**大字门限的一半前提——WCAG 的 18.66px 一档
379
+ * 是专门给粗体的:粗体笔画更重,在更小字号下仍然好认,所以阈值可以降;
380
+ * 常规字重就必须到 24px 才放宽。(早先的规则表把这两个数字挂反了,2026-11-07 已按原文改回。)
381
+ */
382
+ export function isLargeText(fontSizePx, bold = false) {
383
+ const fs = Number(fontSizePx);
384
+ if (!Number.isFinite(fs)) return false;
385
+ return bold ? fs >= 18.66 : fs >= 24;
386
+ }
387
+
388
+ /* --------------------------- 5. 可执行修复建议 --------------------------- */
389
+
390
+ /**
391
+ * 把前景朝黑(target=0)或白(target=255)按 1% 步长推,返回第一个达标的颜色。
392
+ * 确定性:同输入必得同输出,不含随机。返回 {hex, value, reached}。
393
+ */
394
+ function suggest(fg, bg, target, scorer) {
395
+ const toWhite = scorer({ r: 255, g: 255, b: 255, a: 1 }) >= scorer({ r: 0, g: 0, b: 0, a: 1 });
396
+ const goal = toWhite ? 255 : 0;
397
+ for (let i = 1; i <= 100; i += 1) {
398
+ const amount = i / 100;
399
+ const cand = {
400
+ r: Math.round(fg.r + (goal - fg.r) * amount),
401
+ g: Math.round(fg.g + (goal - fg.g) * amount),
402
+ b: Math.round(fg.b + (goal - fg.b) * amount),
403
+ a: 1
404
+ };
405
+ const value = scorer(cand);
406
+ if (value >= target) return { hex: toHex(cand), value, reached: true, lighter: toWhite };
407
+ }
408
+ const end = toWhite ? { r: 255, g: 255, b: 255, a: 1 } : { r: 0, g: 0, b: 0, a: 1 };
409
+ return { hex: toHex(end), value: scorer(end), reached: false, lighter: toWhite };
410
+ }
411
+
412
+ /* ------------------------------ 6. 规则引擎 ------------------------------ */
413
+
414
+ const MAX_PER_RULE = 3;
415
+
416
+ const fmtRatio = (value) => `${value.toFixed(2)}:1`;
417
+ const fmtNum = (value) => Number(value.toFixed(2)).toString();
418
+
419
+ /** 「#RRGGBB(alpha 0.5)」这种人也能读的色值描述。 */
420
+ function describe(color) {
421
+ const hex = toHex(color);
422
+ return color.a < 1 ? `${hex}(alpha ${color.a.toFixed(2)})` : hex;
423
+ }
424
+
425
+ /**
426
+ * 批量审计文字对比度。
427
+ * @param {Array<{fg:string, bg:string, fontSizePx:number, bold?:boolean, where?:string}>} pairs
428
+ * @returns {Array<{severity:string, rule:string, message:string, fix:string, where?:string}>}
429
+ * 按 contrastRatio 从低到高排(最糟的先出);无法解析的 info 排在最后;
430
+ * 每条 rule 最多 3 条。
431
+ */
432
+ export function auditContrast(pairs) {
433
+ const list = Array.isArray(pairs) ? pairs : [];
434
+ const records = [];
435
+ const where = (value) => (typeof value === "string" && value !== "" ? value : undefined);
436
+
437
+ list.forEach((pair, index) => {
438
+ if (pair === null || typeof pair !== "object") return;
439
+ // 两个都不是字符串 = 不是一条对比度色对(调用方字段缺失/传错),静默跳过
440
+ if (typeof pair.fg !== "string" && typeof pair.bg !== "string") return;
441
+ const spot = where(pair.where);
442
+ const fontSizePx = Number(pair.fontSizePx);
443
+ const bold = Boolean(pair.bold);
444
+ const fgRaw = typeof pair.fg === "string" ? pair.fg : "";
445
+ const bgRaw = typeof pair.bg === "string" ? pair.bg : "";
446
+ const fg = parseColor(fgRaw);
447
+ const bg = parseColor(bgRaw);
448
+
449
+ // 解析不出来:明确跳过(不猜、不报 error),但要让调用方知道这条没测
450
+ if (fg === null || bg === null) {
451
+ const bad = fg === null ? "前景色" : "背景色";
452
+ const raw = fg === null ? fgRaw : bgRaw;
453
+ records.push({
454
+ rule: "contrast-invalid",
455
+ sortKey: Number.POSITIVE_INFINITY,
456
+ index,
457
+ build: () =>
458
+ finding({
459
+ severity: "info",
460
+ rule: "contrast-invalid",
461
+ message: `${bad}「${raw === "" ? "(空)" : raw}」无法解析成具体颜色(可能是 currentColor / inherit / CSS 变量),这条对比度检查已跳过`,
462
+ fix: `在 CSS 里把它落成具体颜色(或传入计算后的值)后再复查;动态色值无法离线判定,别硬猜`,
463
+ ...(spot !== undefined ? { where: spot } : {})
464
+ })
465
+ });
466
+ return;
467
+ }
468
+
469
+ const paintedFg = over(fg, bg);
470
+ const paintedBg = bg.a < 1 ? over(bg, { r: 255, g: 255, b: 255, a: 1 }) : bg;
471
+ const ratio = contrastRatio(paintedFg, paintedBg);
472
+ const large = isLargeText(fontSizePx, bold);
473
+ const label = large ? "大字" : "正文";
474
+ const fgText = describe(fg);
475
+ const bgText = describe(bg);
476
+ const alphaHint = fg.a < 1 ? `,合成后 ${toHex(paintedFg)}` : "";
477
+ const aa = large ? AA_LARGE : AA_BODY;
478
+ const aaa = large ? AAA_LARGE : AAA_BODY;
479
+
480
+ if (ratio < aa) {
481
+ records.push({
482
+ rule: "contrast-wcag-aa",
483
+ sortKey: ratio,
484
+ index,
485
+ build: () => {
486
+ const plan = suggest(paintedFg, paintedBg, aa, (c) => contrastRatio(c, paintedBg));
487
+ const moved = fg.a < 1 ? `把前景改成不透明的 ${plan.hex}` : `把前景${plan.lighter ? "提亮" : "加深"}到 ${plan.hex}`;
488
+ const fix = plan.reached
489
+ ? `${moved}(ratio ${fmtRatio(plan.value)},达到 ${aa}:1)`
490
+ : `即使把前景换成 ${plan.hex} 也只有 ratio ${fmtRatio(plan.value)},只改前景不够:同时调${plan.lighter ? "暗" : "亮"}背景 ${bgText} 或换更大字号才算达标`;
491
+ return finding({
492
+ severity: "error",
493
+ rule: "contrast-wcag-aa",
494
+ message: `${label}对比度 ${fmtRatio(ratio)}(${fgText} on ${bgText}${alphaHint}),低于 WCAG AA 的 ${aa}:1`,
495
+ fix,
496
+ ...(spot !== undefined ? { where: spot } : {})
497
+ });
498
+ }
499
+ });
500
+ } else if (ratio < aaa) {
501
+ records.push({
502
+ rule: "contrast-wcag-aaa",
503
+ sortKey: ratio,
504
+ index,
505
+ build: () => {
506
+ const plan = suggest(paintedFg, paintedBg, aaa, (c) => contrastRatio(c, paintedBg));
507
+ const moved = fg.a < 1 ? `把前景改成不透明的 ${plan.hex}` : `把前景${plan.lighter ? "提亮" : "加深"}到 ${plan.hex}`;
508
+ const fix = plan.reached
509
+ ? `可选提升:${moved}(ratio ${fmtRatio(plan.value)},达到 AAA ${aaa}:1)`
510
+ : `只改前景到 ${plan.hex} 也只到 ratio ${fmtRatio(plan.value)};要上 AAA ${aaa}:1 得同时动背景 ${bgText}`;
511
+ return finding({
512
+ severity: "info",
513
+ rule: "contrast-wcag-aaa",
514
+ message: `${label}对比度 ${fmtRatio(ratio)}(${fgText} on ${bgText}${alphaHint}),已过 AA ${aa}:1,未到 AAA ${aaa}:1 —— 长文正文建议往 AAA 提升`,
515
+ fix,
516
+ ...(spot !== undefined ? { where: spot } : {})
517
+ });
518
+ }
519
+ });
520
+ }
521
+
522
+ const lc = apcaLc(paintedFg, paintedBg, fontSizePx, bold);
523
+ const apcaMin = large ? APCA_LARGE : APCA_BODY;
524
+ if (Math.abs(lc) < apcaMin) {
525
+ records.push({
526
+ rule: "contrast-apca",
527
+ sortKey: ratio,
528
+ index,
529
+ build: () => {
530
+ const plan = suggest(paintedFg, paintedBg, apcaMin, (c) => Math.abs(apcaLc(c, paintedBg, fontSizePx, bold)));
531
+ const moved = fg.a < 1 ? `把前景改成不透明的 ${plan.hex}` : `把前景${plan.lighter ? "提亮" : "加深"}到 ${plan.hex}`;
532
+ const fix = plan.reached
533
+ ? `${moved}(Lc ${fmtNum(plan.value)},达到 |Lc| ${apcaMin})`
534
+ : `前景推到 ${plan.hex} 也只到 Lc ${fmtNum(plan.value)};需要动背景 ${bgText} 或加大字号/加粗`;
535
+ return finding({
536
+ severity: "warn",
537
+ rule: "contrast-apca",
538
+ message: `APCA Lc ${fmtNum(lc)}(${fgText} on ${bgText}${alphaHint},${Number.isFinite(fontSizePx) && fontSizePx > 0 ? fmtNum(fontSizePx) : 16}px ${label}),绝对值低于 APCA 建议的 ${apcaMin},实际观感偏弱`,
539
+ fix,
540
+ ...(spot !== undefined ? { where: spot } : {})
541
+ });
542
+ }
543
+ });
544
+ }
545
+
546
+ if (fg.a < LOW_ALPHA) {
547
+ records.push({
548
+ rule: "contrast-low-alpha",
549
+ sortKey: ratio,
550
+ index,
551
+ build: () =>
552
+ finding({
553
+ severity: "warn",
554
+ rule: "contrast-low-alpha",
555
+ message: `前景 ${fgText} on ${bgText} 的 alpha 只有 ${fg.a.toFixed(2)}(< ${LOW_ALPHA}),半透明文字会透出下层,真实对比度低于按合成色算出的 ${fmtRatio(ratio)}`,
556
+ fix: `改用不透明的 ${toHex(paintedFg)}(与当前合成色等效,ratio ${fmtRatio(ratio)},可被准确测量);确实要半透明时别把这段文字压在图片或渐变上`,
557
+ ...(spot !== undefined ? { where: spot } : {})
558
+ })
559
+ });
560
+ }
561
+ });
562
+
563
+ const byRule = new Map();
564
+ for (const record of records) {
565
+ if (!byRule.has(record.rule)) byRule.set(record.rule, []);
566
+ byRule.get(record.rule).push(record);
567
+ }
568
+ const kept = [];
569
+ for (const group of byRule.values()) {
570
+ group.sort((a, b) => a.sortKey - b.sortKey || a.index - b.index);
571
+ kept.push(...group.slice(0, MAX_PER_RULE));
572
+ }
573
+ kept.sort((a, b) => a.sortKey - b.sortKey || a.index - b.index);
574
+ return kept.map((record) => record.build());
575
+ }