@birdapi/velinstyle 0.7.0 → 0.9.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 (336) hide show
  1. package/README.de.md +135 -224
  2. package/README.md +134 -223
  3. package/cli/blueprint.js +8 -0
  4. package/cli/blueprints/bottom-nav-mobile.html +17 -0
  5. package/cli/blueprints/cookie-consent.html +9 -0
  6. package/cli/blueprints/empty-state.html +5 -0
  7. package/cli/blueprints/filter-bar.html +15 -0
  8. package/cli/blueprints/notification-center.html +13 -0
  9. package/cli/blueprints/onboarding.html +23 -0
  10. package/cli/blueprints/pricing-table.html +20 -0
  11. package/cli/blueprints/settings-panel.html +20 -0
  12. package/cli/cli-manifest.json +97 -0
  13. package/cli/docgen/extract-a11y.js +89 -0
  14. package/cli/docgen/extract-attributes.js +95 -0
  15. package/cli/docgen/extract-cli.js +50 -0
  16. package/cli/docgen/extract-components.js +175 -0
  17. package/cli/docgen/extract-meta.js +32 -0
  18. package/cli/docgen/extract-rules.js +76 -0
  19. package/cli/docgen/extract-tokens.js +79 -0
  20. package/cli/docgen/extract-utilities.js +75 -0
  21. package/cli/docgen/markdown.js +43 -0
  22. package/cli/docs-generate.js +196 -0
  23. package/cli/index.js +236 -5
  24. package/cli/layout-audit.js +325 -0
  25. package/cli/meta.js +97 -0
  26. package/cli/perf-audit.js +174 -0
  27. package/cli/pii-scanner.js +134 -0
  28. package/cli/scaffold-recipes.json +70 -0
  29. package/cli/scaffold.js +155 -0
  30. package/cli/scanner-rules-data.js +62 -0
  31. package/cli/scanner.js +136 -1
  32. package/cli/search-index.js +163 -0
  33. package/cli/tokens-build.js +21 -1
  34. package/cli/tokens-validate.js +69 -0
  35. package/components/a11y-entry.js +42 -0
  36. package/components/a11y-utils.js +71 -0
  37. package/components/focus-manager.js +45 -8
  38. package/components/index.js +40 -11
  39. package/components/runtime/component-loaders.js +43 -0
  40. package/components/runtime/index.js +77 -0
  41. package/components/runtime-entry.js +10 -0
  42. package/components/sanitize.js +135 -28
  43. package/components/shadow-a11y-styles.js +18 -0
  44. package/components/velin-accordion.js +12 -0
  45. package/components/velin-announcer.js +35 -0
  46. package/components/velin-bottom-nav.js +108 -0
  47. package/components/velin-carousel.js +2 -2
  48. package/components/velin-code-block.js +142 -0
  49. package/components/velin-combobox.js +149 -0
  50. package/components/velin-command.js +142 -0
  51. package/components/velin-copy.js +18 -5
  52. package/components/velin-counter.js +155 -0
  53. package/components/velin-dialog.js +9 -1
  54. package/components/velin-email.js +94 -0
  55. package/components/velin-flip.js +220 -0
  56. package/components/velin-icon.js +99 -22
  57. package/components/velin-lightbox.js +8 -3
  58. package/components/velin-live-dot.js +90 -0
  59. package/components/velin-menubar.js +83 -0
  60. package/components/velin-modal.js +6 -3
  61. package/components/velin-popover.js +34 -2
  62. package/components/velin-rating.js +91 -0
  63. package/components/velin-reveal.js +22 -0
  64. package/components/velin-scroll-top.js +1 -1
  65. package/components/velin-search.js +267 -0
  66. package/components/velin-secure-field.js +99 -0
  67. package/components/velin-segmented-control.js +108 -0
  68. package/components/velin-sheet.js +107 -0
  69. package/components/velin-sparkline.js +227 -0
  70. package/components/{velin-stepper-wc.js → velin-stepper.js} +37 -8
  71. package/components/velin-theme-toggle.js +284 -60
  72. package/components/velin-toast.js +4 -0
  73. package/components/{velin-tooltip-wc.js → velin-tooltip.js} +22 -3
  74. package/core/a11y/component-contracts.json +221 -0
  75. package/core/attributes/index.js +6 -0
  76. package/core/attributes/registry.js +268 -0
  77. package/core/highlight/highlight.js +114 -0
  78. package/core/highlight/index.js +29 -0
  79. package/core/highlight/languages/_utils.js +52 -0
  80. package/core/highlight/languages/blade.js +16 -0
  81. package/core/highlight/languages/css.js +26 -0
  82. package/core/highlight/languages/html.js +17 -0
  83. package/core/highlight/languages/js.js +58 -0
  84. package/core/highlight/languages/json.js +14 -0
  85. package/core/highlight/languages/markdown.js +16 -0
  86. package/core/highlight/languages/php.js +39 -0
  87. package/core/highlight/languages/plain.js +7 -0
  88. package/core/highlight/languages/shell.js +33 -0
  89. package/core/highlight/languages/sql.js +20 -0
  90. package/core/highlight/languages/typescript.js +28 -0
  91. package/core/highlight/observe.js +53 -0
  92. package/core/highlight/registry.js +107 -0
  93. package/core/highlight/render.js +35 -0
  94. package/core/highlight/types.js +28 -0
  95. package/core/meta/build.js +293 -0
  96. package/core/meta/index.js +17 -0
  97. package/core/meta/schema.js +49 -0
  98. package/core/motion/effects.js +54 -0
  99. package/core/motion/index.js +76 -0
  100. package/core/motion/scheduler.js +72 -0
  101. package/core/motion/scroll.js +32 -0
  102. package/core/motion/stagger.js +24 -0
  103. package/core/search/docs-url.js +96 -0
  104. package/core/search/engine.js +139 -0
  105. package/core/search/highlight.js +48 -0
  106. package/core/search/index.js +126 -0
  107. package/core/search/providers.js +36 -0
  108. package/core/search/types.js +61 -0
  109. package/core/search/worker-client.js +56 -0
  110. package/core/search/worker.js +20 -0
  111. package/dist/chunks/a11y-utils-IRC7LOPF.js +16 -0
  112. package/dist/chunks/attributes-3BZLI7XF.js +364 -0
  113. package/dist/chunks/attributes-4N5JP4CG.js +364 -0
  114. package/dist/chunks/attributes-5Q2WXGRC.js +374 -0
  115. package/dist/chunks/attributes-AIR7SVLK.js +364 -0
  116. package/dist/chunks/attributes-BGRN5XZO.js +364 -0
  117. package/dist/chunks/attributes-BVSQAYLR.js +364 -0
  118. package/dist/chunks/attributes-CDYFUY7Y.js +364 -0
  119. package/dist/chunks/attributes-JMYJZBPE.js +364 -0
  120. package/dist/chunks/attributes-R5JY3X4O.js +374 -0
  121. package/dist/chunks/attributes-STYCS3O5.js +391 -0
  122. package/dist/chunks/attributes-TNSPQTHX.js +374 -0
  123. package/dist/chunks/attributes-TPANLMLF.js +391 -0
  124. package/dist/chunks/blade-LIQLVKZQ.js +19 -0
  125. package/dist/chunks/browser-MRKYZPEH.js +1149 -0
  126. package/dist/chunks/chunk-2354NEBP.js +109 -0
  127. package/dist/chunks/chunk-2VNA7G7S.js +109 -0
  128. package/dist/chunks/chunk-42VVPW6N.js +132 -0
  129. package/dist/chunks/chunk-7FFAMRFV.js +44 -0
  130. package/dist/chunks/chunk-7MCGPHVC.js +69 -0
  131. package/dist/chunks/chunk-AQLRJSGG.js +109 -0
  132. package/dist/chunks/chunk-EWPSDMQT.js +109 -0
  133. package/dist/chunks/chunk-F7OLHTGX.js +22 -0
  134. package/dist/chunks/chunk-J472ERN2.js +242 -0
  135. package/dist/chunks/chunk-LA7264K4.js +85 -0
  136. package/dist/chunks/chunk-M5CHQVP6.js +61 -0
  137. package/dist/chunks/chunk-MKZP2TBH.js +89 -0
  138. package/dist/chunks/chunk-QTJJ3DPO.js +109 -0
  139. package/dist/chunks/chunk-RDE3QE7Z.js +89 -0
  140. package/dist/chunks/chunk-RSMRR27R.js +109 -0
  141. package/dist/chunks/chunk-RZQVWFHW.js +109 -0
  142. package/dist/chunks/chunk-SRL6EWHP.js +109 -0
  143. package/dist/chunks/chunk-U4XHMZ7D.js +109 -0
  144. package/dist/chunks/chunk-V5YIUA6G.js +109 -0
  145. package/dist/chunks/chunk-XIET2VSZ.js +109 -0
  146. package/dist/chunks/chunk-XYLJ2GCB.js +205 -0
  147. package/dist/chunks/chunk-Z4JYQ2XX.js +51 -0
  148. package/dist/chunks/chunk-ZPSPKUYD.js +109 -0
  149. package/dist/chunks/css-MQ5YU77P.js +28 -0
  150. package/dist/chunks/highlight-GJHHV4KM.js +37 -0
  151. package/dist/chunks/highlight-XTHRKBA3.js +37 -0
  152. package/dist/chunks/html-ZODC4DLQ.js +20 -0
  153. package/dist/chunks/js-MNSNYTJU.js +7 -0
  154. package/dist/chunks/json-VEM2HFH3.js +17 -0
  155. package/dist/chunks/markdown-E7OPGS3S.js +19 -0
  156. package/dist/chunks/php-MLL7T3LT.js +41 -0
  157. package/dist/chunks/plain-LJJDC5TN.js +8 -0
  158. package/dist/chunks/runtime-entry.js +14 -0
  159. package/dist/chunks/shell-QTRK4U4E.js +35 -0
  160. package/dist/chunks/sql-PPWLHGJ2.js +23 -0
  161. package/dist/chunks/typescript-JT3CCYWL.js +30 -0
  162. package/dist/chunks/velin-accordion-FBIPAEXG.js +105 -0
  163. package/dist/chunks/velin-accordion-QAQO7BI7.js +117 -0
  164. package/dist/chunks/velin-announcer-PVTXXCWR.js +33 -0
  165. package/dist/chunks/velin-bottom-nav-2N2TYWOF.js +86 -0
  166. package/dist/chunks/velin-bottom-nav-7GEMPIM6.js +86 -0
  167. package/dist/chunks/velin-bottom-nav-CTU6FFZV.js +101 -0
  168. package/dist/chunks/velin-bottom-nav-N3DFQR66.js +86 -0
  169. package/dist/chunks/velin-carousel-Y5VCOABZ.js +213 -0
  170. package/dist/chunks/velin-carousel-ZTBANFQQ.js +213 -0
  171. package/dist/chunks/velin-code-block-ASUBTAQV.js +119 -0
  172. package/dist/chunks/velin-code-block-CIAURDRJ.js +132 -0
  173. package/dist/chunks/velin-code-block-TSUKPY4C.js +123 -0
  174. package/dist/chunks/velin-code-block-Y6CM726Q.js +119 -0
  175. package/dist/chunks/velin-collapse-5XYBUIID.js +80 -0
  176. package/dist/chunks/velin-combobox-2KDTCQ7N.js +155 -0
  177. package/dist/chunks/velin-combobox-NXS4QB4R.js +155 -0
  178. package/dist/chunks/velin-combobox-UGA56HP3.js +155 -0
  179. package/dist/chunks/velin-command-CKDNO4BW.js +148 -0
  180. package/dist/chunks/velin-command-M7RNHUPR.js +136 -0
  181. package/dist/chunks/velin-command-ONKO2A3J.js +136 -0
  182. package/dist/chunks/velin-command-TMLSRGIU.js +136 -0
  183. package/dist/chunks/velin-copy-G2TZ5YBB.js +81 -0
  184. package/dist/chunks/velin-copy-NYDBOKXT.js +81 -0
  185. package/dist/chunks/velin-copy-T5KDTDXP.js +81 -0
  186. package/dist/chunks/velin-countdown-4ZVDGK4R.js +101 -0
  187. package/dist/chunks/velin-countdown-5XSTN3BH.js +101 -0
  188. package/dist/chunks/velin-countdown-7AIY66CU.js +101 -0
  189. package/dist/chunks/velin-counter-ISCTEAHI.js +118 -0
  190. package/dist/chunks/velin-counter-L5FYJF7A.js +121 -0
  191. package/dist/chunks/velin-dialog-D2GUHSCD.js +144 -0
  192. package/dist/chunks/velin-dialog-ELFOZIXZ.js +152 -0
  193. package/dist/chunks/velin-dialog-GTTZI564.js +147 -0
  194. package/dist/chunks/velin-dialog-IJLFDXAJ.js +144 -0
  195. package/dist/chunks/velin-dialog-TNS3WXMB.js +144 -0
  196. package/dist/chunks/velin-drawer-BE6TYN3H.js +130 -0
  197. package/dist/chunks/velin-drawer-DLL2GLT6.js +130 -0
  198. package/dist/chunks/velin-drawer-MJS6WCF7.js +130 -0
  199. package/dist/chunks/velin-dropdown-PLUIFA5M.js +172 -0
  200. package/dist/chunks/velin-email-N5B5ZYTE.js +90 -0
  201. package/dist/chunks/velin-email-NTM6DG3V.js +90 -0
  202. package/dist/chunks/velin-email-RBX6ZR4S.js +90 -0
  203. package/dist/chunks/velin-haptic-4QQTM3RK.js +50 -0
  204. package/dist/chunks/velin-icon-7M76DOPB.js +164 -0
  205. package/dist/chunks/velin-icon-KZO3IEHG.js +164 -0
  206. package/dist/chunks/velin-icon-WTFPG2DD.js +191 -0
  207. package/dist/chunks/velin-icon-ZTSKUOVN.js +193 -0
  208. package/dist/chunks/velin-lightbox-6E7CAATP.js +149 -0
  209. package/dist/chunks/velin-lightbox-I75U6JF4.js +140 -0
  210. package/dist/chunks/velin-lightbox-QOTB4PRL.js +140 -0
  211. package/dist/chunks/velin-lightbox-UCD6N7HF.js +140 -0
  212. package/dist/chunks/velin-live-dot-RJHX2DGY.js +74 -0
  213. package/dist/chunks/velin-live-dot-XHUZCIDZ.js +67 -0
  214. package/dist/chunks/velin-menubar-I426AED2.js +86 -0
  215. package/dist/chunks/velin-menubar-J66JXRWW.js +86 -0
  216. package/dist/chunks/velin-menubar-SFEXJE56.js +86 -0
  217. package/dist/chunks/velin-modal-2HOBXE6F.js +176 -0
  218. package/dist/chunks/velin-modal-5HYNNNUI.js +176 -0
  219. package/dist/chunks/velin-modal-AEOTZ67O.js +177 -0
  220. package/dist/chunks/velin-modal-GLI2XZZB.js +176 -0
  221. package/dist/chunks/velin-persist-DFPL3QP4.js +110 -0
  222. package/dist/chunks/velin-popover-EXINRIGJ.js +145 -0
  223. package/dist/chunks/velin-popover-HQW6Q2Y6.js +172 -0
  224. package/dist/chunks/velin-popover-OXX7MRZ6.js +145 -0
  225. package/dist/chunks/velin-popover-T72HK3DF.js +151 -0
  226. package/dist/chunks/velin-popover-YKCCROJG.js +145 -0
  227. package/dist/chunks/velin-progress-ring-E4YRLZHH.js +66 -0
  228. package/dist/chunks/velin-rating-6CDBI6DG.js +91 -0
  229. package/dist/chunks/velin-rating-N4DI2XNL.js +91 -0
  230. package/dist/chunks/velin-rating-XRQJV4LO.js +91 -0
  231. package/dist/chunks/velin-scroll-top-F2FCZNYD.js +58 -0
  232. package/dist/chunks/velin-scroll-top-OHYVVHHY.js +58 -0
  233. package/dist/chunks/velin-scrollspy-OKCT4FDV.js +50 -0
  234. package/dist/chunks/velin-search-2XRQFBCS.js +556 -0
  235. package/dist/chunks/velin-search-4YHJWYSS.js +556 -0
  236. package/dist/chunks/velin-search-7B4KJ6TX.js +557 -0
  237. package/dist/chunks/velin-search-LWHY23Q5.js +474 -0
  238. package/dist/chunks/velin-secure-field-6PV7RZDJ.js +95 -0
  239. package/dist/chunks/velin-secure-field-H7FSBUJW.js +95 -0
  240. package/dist/chunks/velin-secure-field-OFNAXCDP.js +95 -0
  241. package/dist/chunks/velin-segmented-control-EKL4E3BL.js +109 -0
  242. package/dist/chunks/velin-segmented-control-GYD5BIXQ.js +109 -0
  243. package/dist/chunks/velin-segmented-control-PQX6LKOJ.js +109 -0
  244. package/dist/chunks/velin-sheet-3OGI4HD7.js +119 -0
  245. package/dist/chunks/velin-sheet-OUK6572T.js +119 -0
  246. package/dist/chunks/velin-sheet-R2Y67X6H.js +119 -0
  247. package/dist/chunks/velin-sparkline-GVEANJVW.js +184 -0
  248. package/dist/chunks/velin-sparkline-KDA3GQAN.js +162 -0
  249. package/dist/chunks/velin-sparkline-XVTFC6GE.js +181 -0
  250. package/dist/chunks/velin-stepper-2ZIHLKGM.js +141 -0
  251. package/dist/chunks/velin-stepper-7ECRUYHD.js +148 -0
  252. package/dist/chunks/velin-stepper-FYEUEWE6.js +141 -0
  253. package/dist/chunks/velin-stepper-X54WGHDG.js +141 -0
  254. package/dist/chunks/velin-stepper-wc-QU3KRVQR.js +133 -0
  255. package/dist/chunks/velin-tabs-5UQ5ZVMV.js +138 -0
  256. package/dist/chunks/velin-theme-toggle-32J2CBNC.js +291 -0
  257. package/dist/chunks/velin-theme-toggle-BNNS7WLV.js +285 -0
  258. package/dist/chunks/velin-theme-toggle-NO7V4TWI.js +285 -0
  259. package/dist/chunks/velin-theme-toggle-QELCJM2Q.js +291 -0
  260. package/dist/chunks/velin-toast-OMMOA2DK.js +109 -0
  261. package/dist/chunks/velin-toast-RCZ4CSUC.js +109 -0
  262. package/dist/chunks/velin-toast-RSF2IHT3.js +113 -0
  263. package/dist/chunks/velin-toast-WEUBTJG6.js +109 -0
  264. package/dist/chunks/velin-tooltip-664IOHZA.js +112 -0
  265. package/dist/chunks/velin-tooltip-BDHGRTQB.js +112 -0
  266. package/dist/chunks/velin-tooltip-HVUOKNVX.js +112 -0
  267. package/dist/chunks/velin-tooltip-wc-LXDVT6DE.js +103 -0
  268. package/dist/chunks/worker-client-R6DBYPFT.js +46 -0
  269. package/dist/llms.test.txt +40 -0
  270. package/dist/llms.txt +40 -0
  271. package/dist/search-index.json +1773 -0
  272. package/dist/search-index.test.json +1773 -0
  273. package/dist/themes/brutalist.min.css +1 -1
  274. package/dist/themes/midnight.min.css +1 -1
  275. package/dist/themes/pastel.min.css +1 -1
  276. package/dist/themes/retro.min.css +1 -1
  277. package/dist/themes/sunset.min.css +1 -1
  278. package/dist/velin-agent.json +684 -0
  279. package/dist/velin-agent.test.json +684 -0
  280. package/dist/velinstyle-components.iife.js +7959 -2199
  281. package/dist/velinstyle-components.js +7982 -2174
  282. package/dist/velinstyle-components.min.js +521 -90
  283. package/dist/velinstyle.css +1290 -282
  284. package/dist/velinstyle.d.ts +54 -0
  285. package/dist/velinstyle.min.css +1 -1
  286. package/examples/search-index.schema.json +33 -0
  287. package/package.json +57 -10
  288. package/src/a11y/authentication.css +37 -0
  289. package/src/a11y/consistent-help.css +22 -0
  290. package/src/a11y/dragging-alternatives.css +30 -0
  291. package/src/a11y/focus-appearance.css +20 -0
  292. package/src/a11y/high-contrast-aaa.css +16 -26
  293. package/src/a11y/security.css +18 -0
  294. package/src/a11y/skip-link.css +3 -1
  295. package/src/a11y/sr-only.css +1 -1
  296. package/src/base/reset.css +0 -25
  297. package/src/base/wc-placeholder.css +102 -0
  298. package/src/components/attributes.css +42 -0
  299. package/src/components/highlight.css +182 -0
  300. package/src/components/input-group.css +1 -2
  301. package/src/components/nav.css +151 -151
  302. package/src/components/search.css +95 -0
  303. package/src/components/stepper.css +2 -4
  304. package/src/components/timeline.css +1 -2
  305. package/src/components/tooltip.css +2 -4
  306. package/src/layout/container.css +5 -5
  307. package/src/layout/grid.css +8 -8
  308. package/src/layout/patterns.css +5 -5
  309. package/src/themes/brutalist.css +2 -11
  310. package/src/themes/corporate.css +0 -9
  311. package/src/themes/earth.css +0 -9
  312. package/src/themes/forest.css +0 -9
  313. package/src/themes/midnight.css +1 -10
  314. package/src/themes/neon.css +0 -9
  315. package/src/themes/nordic.css +0 -9
  316. package/src/themes/ocean.css +0 -9
  317. package/src/themes/pastel.css +1 -10
  318. package/src/themes/retro.css +2 -11
  319. package/src/themes/sharp.css +0 -7
  320. package/src/themes/soft.css +0 -7
  321. package/src/themes/sunset.css +1 -10
  322. package/src/tokens/color.css +45 -46
  323. package/src/tokens/fonts.css +10 -0
  324. package/src/tokens/motion.css +7 -0
  325. package/src/tokens/typography.css +4 -5
  326. package/src/utilities/animation.css +99 -3
  327. package/src/utilities/chart-animation.css +101 -0
  328. package/src/utilities/display.css +3 -3
  329. package/src/utilities/divide.css +2 -6
  330. package/src/utilities/filter-effects.css +103 -0
  331. package/src/utilities/responsive.css +4 -4
  332. package/src/utilities/safe-area.css +39 -0
  333. package/src/utilities/scroll-animation.css +34 -0
  334. package/src/utilities/spacing.css +17 -8
  335. package/src/utilities/text.css +4 -1
  336. package/src/velinstyle.css +12 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Scanner rule metadata for documentation and tooling.
3
+ * Scan logic lives in scanner.js / pii-scanner.js / perf-audit.js.
4
+ */
5
+
6
+ /** @typedef {{ id: string, category: string, severity: string, message: string, fixHint?: string }} ScannerRuleMeta */
7
+
8
+ /** @type {ScannerRuleMeta[]} */
9
+ export const SCANNER_RULES = [
10
+ // Security — HTML
11
+ { id: 'security/no-inline-handler', category: 'security', severity: 'warning', message: 'Inline event handler found. Use addEventListener() instead.', fixHint: 'Move handler to JS module.' },
12
+ { id: 'security/no-javascript-url', category: 'security', severity: 'error', message: 'javascript: URL detected. XSS vector.', fixHint: 'Use button + script or href to safe URL.' },
13
+ { id: 'security/safe-external-link', category: 'security', severity: 'warning', message: 'target="_blank" without rel="noopener noreferrer".', fixHint: 'Add rel="noopener noreferrer" — auto-fix with --fix.' },
14
+ { id: 'security/no-meta-refresh', category: 'security', severity: 'error', message: '<meta http-equiv="refresh"> can redirect without consent.', fixHint: 'Use server redirect or JS with user action.' },
15
+ { id: 'security/no-inline-style', category: 'security', severity: 'warning', message: 'Inline style attribute increases XSS surface.', fixHint: 'Use VelinStyle utility classes.' },
16
+ { id: 'security/no-data-html-uri', category: 'security', severity: 'error', message: 'data:text/html URI can execute script.', fixHint: 'Avoid data: HTML URIs in href/src.' },
17
+ { id: 'security/dangerous-target', category: 'security', severity: 'warning', message: '<form target="_blank"> is unusual.', fixHint: 'Use same-tab navigation.' },
18
+ { id: 'security/integrity-missing', category: 'security', severity: 'info', message: 'External <script> without integrity (SRI).', fixHint: 'Add integrity + crossorigin for CDN scripts.' },
19
+ { id: 'security/csp-meta', category: 'security', severity: 'info', message: 'No CSP meta tag found.', fixHint: 'Add Content-Security-Policy header or meta.' },
20
+ // Security — JS
21
+ { id: 'security/no-raw-innerhtml', category: 'security', severity: 'warning', message: 'Direct innerHTML without sanitization.', fixHint: 'Use escapeHTML() from @birdapi/velinstyle/sanitize.' },
22
+ { id: 'security/no-document-write', category: 'security', severity: 'error', message: 'document.write() is dangerous.', fixHint: 'Use DOM APIs or template elements.' },
23
+ { id: 'security/no-eval', category: 'security', severity: 'error', message: 'eval() is a critical risk.', fixHint: 'Never use eval in production code.' },
24
+ { id: 'security/no-function-constructor', category: 'security', severity: 'error', message: 'new Function() equals eval().', fixHint: 'Refactor to static functions.' },
25
+ { id: 'security/postmessage-wildcard', category: 'security', severity: 'warning', message: 'postMessage with targetOrigin "*".', fixHint: 'Specify exact target origin.' },
26
+ // PII
27
+ { id: 'pii/hardcoded-email', category: 'pii', severity: 'warning', message: 'Hardcoded email in source.', fixHint: 'Use env/config or <velin-email>. --fix masks to placeholder.' },
28
+ { id: 'pii/mailto-in-source', category: 'pii', severity: 'info', message: 'mailto: exposes email in HTML.', fixHint: 'Consider <velin-email> obfuscation.' },
29
+ { id: 'pii/hardcoded-secret', category: 'pii', severity: 'error', message: 'Possible API key or secret in source.', fixHint: 'Use environment variables or secret manager.' },
30
+ { id: 'pii/localstorage-pii', category: 'pii', severity: 'warning', message: 'Email-like data in Web Storage.', fixHint: 'Avoid storing PII in localStorage.' },
31
+ // A11y
32
+ { id: 'a11y/html-lang', category: 'a11y', severity: 'error', message: '<html> missing lang attribute.', fixHint: 'Add lang="de" or appropriate locale — --fix.' },
33
+ { id: 'a11y/img-alt', category: 'a11y', severity: 'error', message: '<img> without alt.', fixHint: 'Add alt text or alt="" for decorative images.' },
34
+ { id: 'a11y/img-decorative', category: 'a11y', severity: 'warning', message: 'Decorative <img alt=""> without aria-hidden.', fixHint: 'Add aria-hidden="true" when visible text carries the meaning.' },
35
+ { id: 'a11y/velin-icon-label', category: 'a11y', severity: 'warning', message: 'velin-icon in icon-only button without label.', fixHint: 'Add label="…" on <velin-icon>.' },
36
+ { id: 'a11y/sparkline-label', category: 'a11y', severity: 'warning', message: 'velin-sparkline without accessible name.', fixHint: 'Add label attribute or wrap in <figure><figcaption>.' },
37
+ { id: 'a11y/skeleton-text', category: 'a11y', severity: 'warning', message: 'velin-skeleton on non-empty element.', fixHint: 'Use skeleton only on empty placeholders.' },
38
+ { id: 'a11y/button-label', category: 'a11y', severity: 'warning', message: '<button> without visible text or aria-label.', fixHint: 'Add text or aria-label.' },
39
+ { id: 'a11y/input-label', category: 'a11y', severity: 'warning', message: '<input> without label association.', fixHint: 'Use <label for> or aria-label.' },
40
+ { id: 'a11y/skip-link', category: 'a11y', severity: 'warning', message: 'No skip link for keyboard users.', fixHint: 'Add .velin-skip-link to #main — --fix when id="main" exists.' },
41
+ { id: 'a11y/landmark-main', category: 'a11y', severity: 'warning', message: 'No <main> landmark.', fixHint: 'Wrap primary content in <main>.' },
42
+ { id: 'a11y/heading-order', category: 'a11y', severity: 'warning', message: 'Heading levels skip (e.g. h2 → h4).', fixHint: 'Use sequential heading levels.' },
43
+ { id: 'a11y/interactive-aria-hidden', category: 'a11y', severity: 'error', message: 'Interactive element with aria-hidden="true".', fixHint: 'Remove aria-hidden or use inert.' },
44
+ { id: 'a11y/iframe-title', category: 'a11y', severity: 'error', message: '<iframe> without title.', fixHint: 'Add descriptive title attribute.' },
45
+ { id: 'a11y/autocomplete-auth', category: 'a11y', severity: 'warning', message: 'Auth field missing autocomplete (WCAG 2.2).', fixHint: 'Add autocomplete="username" or "current-password".' },
46
+ { id: 'a11y/invalid-describedby', category: 'a11y', severity: 'warning', message: 'aria-invalid without aria-describedby.', fixHint: 'Link to error message element id.' },
47
+ // CSS
48
+ { id: 'css/var-fallback', category: 'css', severity: 'info', message: 'CSS variable without fallback in var().', fixHint: 'Use var(--velin-*, fallback).' },
49
+ { id: 'css/z-index-token', category: 'css', severity: 'warning', message: 'Raw z-index instead of --velin-z-* token.', fixHint: 'Use tokens from z-index.css — --fix suggests token.' },
50
+ { id: 'css/no-important', category: 'css', severity: 'info', message: '!important usage.', fixHint: 'Prefer @layer and specificity.' },
51
+ { id: 'css/vendor-prefix', category: 'css', severity: 'info', message: 'Unnecessary vendor prefix.', fixHint: 'Lightning CSS handles autoprefixing.' },
52
+ ];
53
+
54
+ /** @type {ScannerRuleMeta[]} */
55
+ export const PERF_RULES = [
56
+ { id: 'perf/img-missing-dimensions', category: 'perf', severity: 'warning', message: '<img> without width/height causes CLS.', fixHint: 'Add width and height attributes — perf fix.' },
57
+ { id: 'perf/img-no-lazy', category: 'perf', severity: 'info', message: 'Consider loading="lazy" for below-fold images.', fixHint: 'Add loading="lazy".' },
58
+ { id: 'perf/script-no-defer', category: 'perf', severity: 'warning', message: 'External script without defer/async.', fixHint: 'Add defer or type="module".' },
59
+ { id: 'perf/large-inline-style', category: 'perf', severity: 'info', message: 'Large inline style block.', fixHint: 'Move to external CSS.' },
60
+ { id: 'perf/font-display-swap', category: 'perf', severity: 'info', message: '@font-face without font-display: swap.', fixHint: 'Add font-display: swap.' },
61
+ { id: 'perf/unused-velin-import', category: 'perf', severity: 'info', message: 'Full component bundle loaded.', fixHint: 'Use @birdapi/velinstyle/runtime for tree-shaking.' },
62
+ ];
package/cli/scanner.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
2
2
  import { join, relative, extname } from 'path';
3
3
  import { applyFixes, fixSafeExternalLinkLine, fixZIndexLine } from './apply-fixes.js';
4
+ import { scanPIIHTML, scanPIIJS } from './pii-scanner.js';
5
+
6
+ export { SCANNER_RULES, PERF_RULES } from './scanner-rules-data.js';
4
7
 
5
8
  const SEVERITY = { error: 0, warning: 1, info: 2 };
6
9
  const SEVERITY_LABEL = { 0: 'ERROR', 1: 'WARNING', 2: 'INFO' };
@@ -80,6 +83,51 @@ function scanSecurityHTML(content, file) {
80
83
  fix: (currentLine) => fixSafeExternalLinkLine(currentLine),
81
84
  });
82
85
  }
86
+
87
+ if (/<meta\s[^>]*http-equiv\s*=\s*["']refresh["']/i.test(line)) {
88
+ issues.push({
89
+ file, line: ln, severity: 0,
90
+ rule: 'security/no-meta-refresh',
91
+ message: '<meta http-equiv="refresh"> can redirect users without consent.',
92
+ fixable: false,
93
+ });
94
+ }
95
+
96
+ if (/\sstyle\s*=\s*["'][^"']+["']/i.test(line) && !/velin-user-content/i.test(line)) {
97
+ issues.push({
98
+ file, line: ln, severity: 1,
99
+ rule: 'security/no-inline-style',
100
+ message: 'Inline style attribute. Prefer CSS classes to reduce XSS surface.',
101
+ fixable: false,
102
+ });
103
+ }
104
+
105
+ if (/(?:href|src)\s*=\s*["']data:text\/html/i.test(line)) {
106
+ issues.push({
107
+ file, line: ln, severity: 0,
108
+ rule: 'security/no-data-html-uri',
109
+ message: 'data:text/html URI can execute script when mishandled.',
110
+ fixable: false,
111
+ });
112
+ }
113
+
114
+ if (/<form\b[^>]*\btarget\s*=\s*["']_blank["']/i.test(line)) {
115
+ issues.push({
116
+ file, line: ln, severity: 1,
117
+ rule: 'security/dangerous-target',
118
+ message: '<form target="_blank"> is unusual and can be abused. Prefer same-tab navigation.',
119
+ fixable: false,
120
+ });
121
+ }
122
+
123
+ if (/<script\b[^>]*\bsrc\s*=\s*["']https?:\/\//i.test(line) && !/\bintegrity\s*=/i.test(line)) {
124
+ issues.push({
125
+ file, line: ln, severity: 2,
126
+ rule: 'security/integrity-missing',
127
+ message: 'External <script> without integrity attribute. Use SRI for CDN scripts.',
128
+ fixable: false,
129
+ });
130
+ }
83
131
  });
84
132
 
85
133
  if (!/<meta\s[^>]*http-equiv\s*=\s*["']Content-Security-Policy["']/i.test(content)) {
@@ -136,11 +184,29 @@ function scanSecurityJS(content, file) {
136
184
  fixable: false,
137
185
  });
138
186
  }
187
+
188
+ if (/\.postMessage\s*\([^)]*,\s*['"]\*['"]\s*\)/.test(line)) {
189
+ issues.push({
190
+ file, line: ln, severity: 1,
191
+ rule: 'security/postmessage-wildcard',
192
+ message: 'postMessage with targetOrigin "*" accepts any origin.',
193
+ fixable: false,
194
+ });
195
+ }
139
196
  });
140
197
 
141
198
  return issues;
142
199
  }
143
200
 
201
+ function issueCategory(rule) {
202
+ if (rule.startsWith('security/')) return 'security';
203
+ if (rule.startsWith('pii/')) return 'pii';
204
+ if (rule.startsWith('a11y/')) return 'a11y';
205
+ if (rule.startsWith('css/')) return 'css';
206
+ if (rule.startsWith('perf/')) return 'perf';
207
+ return 'other';
208
+ }
209
+
144
210
  // ── Accessibility Scanner ────────────────────────────────────────────────────
145
211
 
146
212
  function scanA11yHTML(content, file) {
@@ -161,13 +227,25 @@ function scanA11yHTML(content, file) {
161
227
 
162
228
  const imgMatches = line.matchAll(/<img\b[^>]*>/gi);
163
229
  for (const m of imgMatches) {
164
- if (!/\balt\s*=/i.test(m[0])) {
230
+ const tag = m[0];
231
+ if (!/\balt\s*=/i.test(tag)) {
165
232
  issues.push({
166
233
  file, line: ln, severity: 0,
167
234
  rule: 'a11y/img-alt',
168
235
  message: '<img> without alt attribute. Add alt="" for decorative or descriptive text.',
169
236
  fixable: false,
170
237
  });
238
+ } else if (
239
+ /\balt\s*=\s*["']\s*["']/i.test(tag)
240
+ && !/\baria-hidden\s*=\s*["']true["']/i.test(tag)
241
+ && !/\brole\s*=\s*["']presentation["']/i.test(tag)
242
+ ) {
243
+ issues.push({
244
+ file, line: ln, severity: 1,
245
+ rule: 'a11y/img-decorative',
246
+ message: 'Decorative <img alt=""> should include aria-hidden="true" so assistive tech ignores it consistently.',
247
+ fixable: false,
248
+ });
171
249
  }
172
250
  }
173
251
 
@@ -183,6 +261,31 @@ function scanA11yHTML(content, file) {
183
261
  }
184
262
  }
185
263
 
264
+ if (/<button\b[^>]*>[\s\S]*<velin-icon\b/i.test(line) && /<velin-icon\b(?![^>]*\blabel\s*=)/i.test(line)) {
265
+ issues.push({
266
+ file, line: ln, severity: 1,
267
+ rule: 'a11y/velin-icon-label',
268
+ message: '<velin-icon> inside icon-only <button> should have a label attribute.',
269
+ fixable: false,
270
+ });
271
+ }
272
+ if (/<velin-sparkline\b/i.test(line) && !/<velin-sparkline\b[^>]*\blabel\s*=/i.test(line) && !/<figcaption/i.test(line)) {
273
+ issues.push({
274
+ file, line: ln, severity: 1,
275
+ rule: 'a11y/sparkline-label',
276
+ message: '<velin-sparkline> needs label or a parent <figure> with <figcaption>.',
277
+ fixable: false,
278
+ });
279
+ }
280
+ if (/\bvelin-skeleton\b/i.test(line) && />\s*[^<\s][^<]+</i.test(line)) {
281
+ issues.push({
282
+ file, line: ln, severity: 1,
283
+ rule: 'a11y/skeleton-text',
284
+ message: 'velin-skeleton on elements with visible text may hide content from assistive tech.',
285
+ fixable: false,
286
+ });
287
+ }
288
+
186
289
  const inputMatches = line.matchAll(/<input\b[^>]*>/gi);
187
290
  for (const m of inputMatches) {
188
291
  const tag = m[0];
@@ -251,6 +354,27 @@ function scanA11yHTML(content, file) {
251
354
  });
252
355
  }
253
356
  }
357
+
358
+ if (/<form\b/i.test(line) && /type\s*=\s*["'](?:password|email)["']/i.test(line)) {
359
+ const formCtx = content.slice(Math.max(0, content.indexOf(line) - 800), content.indexOf(line) + 200);
360
+ if (/type\s*=\s*["']password["']/i.test(formCtx) && !/autocomplete\s*=/i.test(line) && !/autocomplete\s*=/i.test(formCtx)) {
361
+ issues.push({
362
+ file, line: ln, severity: 1,
363
+ rule: 'a11y/autocomplete-auth',
364
+ message: 'Auth field missing autocomplete (WCAG 2.2 / 1.3.5). Add autocomplete="username" or "current-password".',
365
+ fixable: false,
366
+ });
367
+ }
368
+ }
369
+
370
+ if (/aria-invalid\s*=\s*["']true["']/i.test(line) && !/aria-describedby/i.test(line)) {
371
+ issues.push({
372
+ file, line: ln, severity: 1,
373
+ rule: 'a11y/invalid-describedby',
374
+ message: 'aria-invalid without aria-describedby linking to error help text.',
375
+ fixable: false,
376
+ });
377
+ }
254
378
  });
255
379
 
256
380
  return issues;
@@ -325,6 +449,9 @@ export function scan(targetPath, options = {}) {
325
449
  const writeFixes = doFix && !fixDryRun;
326
450
  const runFixPipeline = doFix || fixDryRun;
327
451
  const ignore = options.ignore || DEFAULT_IGNORE;
452
+ const onlyCategories = options.only
453
+ ? options.only.split(',').map((s) => s.trim().toLowerCase())
454
+ : null;
328
455
 
329
456
  const htmlFiles = walkFiles(targetPath, ['.html', '.htm'], ignore);
330
457
  const cssFiles = walkFiles(targetPath, ['.css'], ignore);
@@ -332,10 +459,13 @@ export function scan(targetPath, options = {}) {
332
459
 
333
460
  let allIssues = [];
334
461
 
462
+ const fixEmailPlaceholder = options.fixEmailPlaceholder || 'user@example.com';
463
+
335
464
  for (const file of htmlFiles) {
336
465
  const content = readFileSync(file, 'utf-8');
337
466
  allIssues.push(...scanSecurityHTML(content, file));
338
467
  allIssues.push(...scanA11yHTML(content, file));
468
+ allIssues.push(...scanPIIHTML(content, file, { fixEmailPlaceholder }));
339
469
  }
340
470
 
341
471
  for (const file of cssFiles) {
@@ -346,9 +476,13 @@ export function scan(targetPath, options = {}) {
346
476
  for (const file of jsFiles) {
347
477
  const content = readFileSync(file, 'utf-8');
348
478
  allIssues.push(...scanSecurityJS(content, file));
479
+ allIssues.push(...scanPIIJS(content, file));
349
480
  }
350
481
 
351
482
  allIssues = allIssues.filter(i => i.severity <= minSeverity);
483
+ if (onlyCategories?.length) {
484
+ allIssues = allIssues.filter((i) => onlyCategories.includes(issueCategory(i.rule)));
485
+ }
352
486
  allIssues.sort((a, b) => a.severity - b.severity || a.file.localeCompare(b.file) || a.line - b.line);
353
487
 
354
488
  let fixSummary = null;
@@ -368,6 +502,7 @@ export function scan(targetPath, options = {}) {
368
502
  file: relative(targetPath, i.file),
369
503
  line: i.line,
370
504
  severity: SEVERITY_LABEL[i.severity],
505
+ category: issueCategory(i.rule),
371
506
  rule: i.rule,
372
507
  message: i.message,
373
508
  fixable: !!i.fixable,
@@ -0,0 +1,163 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join, dirname, relative, basename } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const PKG_ROOT = join(__dirname, '..');
7
+
8
+ const TITLE_RE = /^#\s+<([^>]+)>|^#\s+(.+)$/m;
9
+ const H1_RE = /^#\s+(.+)$/m;
10
+
11
+ function excerptFromMd(text, max = 160) {
12
+ const lines = text.split('\n');
13
+ for (const line of lines) {
14
+ if (line.startsWith('#') || !line.trim() || line.startsWith('|') || line.startsWith('<!--')) continue;
15
+ return line.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`/g, '').trim().slice(0, max);
16
+ }
17
+ return '';
18
+ }
19
+
20
+ function slugId(category, name) {
21
+ return `${category}:${name}`.replace(/\s+/g, '-').toLowerCase();
22
+ }
23
+
24
+ function walkFiles(dir, cb) {
25
+ if (!existsSync(dir)) return;
26
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
27
+ const full = join(dir, ent.name);
28
+ if (ent.isDirectory()) walkFiles(full, cb);
29
+ else cb(full);
30
+ }
31
+ }
32
+
33
+ function collectMarkdown(dir, category, urlPrefix, weight = 1) {
34
+ const entries = [];
35
+ if (!existsSync(dir)) return entries;
36
+ walkFiles(dir, (full) => {
37
+ const name = basename(full);
38
+ if (!name.endsWith('.md') || name === 'README.md') return;
39
+ const text = readFileSync(full, 'utf-8');
40
+ const tagMatch = text.match(TITLE_RE);
41
+ const h1Match = text.match(H1_RE);
42
+ const title = tagMatch
43
+ ? (tagMatch[1] || tagMatch[2] || basename(name, '.md')).trim()
44
+ : (h1Match ? h1Match[1] : basename(name, '.md')).replace(/`/g, '').trim();
45
+ const rel = relative(dir, full).replace(/\\/g, '/');
46
+ const base = basename(name, '.md');
47
+ entries.push({
48
+ id: slugId(category, base),
49
+ title,
50
+ excerpt: excerptFromMd(text),
51
+ url: `${urlPrefix}${rel.replace(/\.md$/, '.html')}`,
52
+ category,
53
+ keywords: [base, category, title],
54
+ weight,
55
+ });
56
+ });
57
+ return entries;
58
+ }
59
+
60
+ function collectHtmlSamples(dirs) {
61
+ const entries = [];
62
+ for (const dir of dirs) {
63
+ if (!existsSync(dir)) continue;
64
+ const walk = (folder) => {
65
+ for (const ent of readdirSync(folder, { withFileTypes: true })) {
66
+ const full = join(folder, ent.name);
67
+ if (ent.isDirectory()) walk(full);
68
+ else if (ent.name.endsWith('.html')) {
69
+ const text = readFileSync(full, 'utf-8');
70
+ const titleM = text.match(/<title>([^<]+)<\/title>/i);
71
+ const h1M = text.match(/<h1[^>]*>([^<]+)<\/h1>/i);
72
+ const title = (h1M?.[1] || titleM?.[1] || ent.name).replace(/<[^>]+>/g, '').trim();
73
+ const rel = relative(PKG_ROOT, full).replace(/\\/g, '/');
74
+ entries.push({
75
+ id: slugId('examples', rel),
76
+ title,
77
+ excerpt: rel,
78
+ url: rel,
79
+ category: 'examples',
80
+ keywords: [ent.name, 'sample', 'example'],
81
+ weight: 0.85,
82
+ });
83
+ }
84
+ }
85
+ };
86
+ walk(dir);
87
+ }
88
+ return entries;
89
+ }
90
+
91
+ function collectCliApi(manifestPath) {
92
+ const entries = [];
93
+ if (!existsSync(manifestPath)) return entries;
94
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
95
+ for (const cmd of manifest.commands || []) {
96
+ entries.push({
97
+ id: slugId('api', cmd.name || cmd.id),
98
+ title: `CLI: ${cmd.name || cmd.id}`,
99
+ excerpt: cmd.summary || cmd.description || '',
100
+ url: `docs/generated/cli/commands.html#${cmd.name || cmd.id}`,
101
+ category: 'api',
102
+ keywords: ['cli', cmd.name, ...(cmd.aliases || [])],
103
+ weight: 1.1,
104
+ });
105
+ }
106
+ return entries;
107
+ }
108
+
109
+ /**
110
+ * @param {object} [options]
111
+ * @param {string} [options.outFile]
112
+ * @param {string} [options.generatedDir]
113
+ * @param {string[]} [options.extraHtmlDirs]
114
+ */
115
+ export function buildSearchIndex(options = {}) {
116
+ const generatedDir = options.generatedDir || join(PKG_ROOT, 'docs', 'generated');
117
+ const outFile = options.outFile || join(PKG_ROOT, 'dist', 'search-index.json');
118
+
119
+ const entries = [
120
+ ...collectMarkdown(join(generatedDir, 'components'), 'components', 'docs/generated/components/', 1.3),
121
+ ...collectMarkdown(join(generatedDir, 'tokens'), 'docs', 'docs/generated/tokens/', 1),
122
+ ...collectMarkdown(join(generatedDir, 'utilities'), 'docs', 'docs/generated/utilities/', 1),
123
+ ...collectMarkdown(join(generatedDir, 'cli'), 'api', 'docs/generated/cli/', 1.15),
124
+ ...collectMarkdown(join(generatedDir, 'rules'), 'docs', 'docs/generated/rules/', 0.95),
125
+ ...collectMarkdown(join(generatedDir, 'a11y'), 'docs', 'docs/generated/a11y/', 1),
126
+ ...collectMarkdown(join(generatedDir, 'attributes'), 'docs', 'docs/generated/attributes/', 1.2),
127
+ ...collectCliApi(join(PKG_ROOT, 'cli', 'cli-manifest.json')),
128
+ ...collectHtmlSamples([
129
+ join(PKG_ROOT, 'samples'),
130
+ join(PKG_ROOT, 'examples'),
131
+ ...(options.extraHtmlDirs || []),
132
+ ]),
133
+ ];
134
+
135
+ const seen = new Set();
136
+ const unique = [];
137
+ for (const e of entries) {
138
+ if (seen.has(e.id)) continue;
139
+ seen.add(e.id);
140
+ unique.push(e);
141
+ }
142
+
143
+ unique.sort((a, b) => a.title.localeCompare(b.title));
144
+
145
+ const payload = {
146
+ version: 1,
147
+ generatedAt: new Date().toISOString(),
148
+ entries: unique,
149
+ };
150
+
151
+ mkdirSync(dirname(outFile), { recursive: true });
152
+ writeFileSync(outFile, JSON.stringify(payload, null, 2), 'utf-8');
153
+ return { ok: true, count: unique.length, outFile };
154
+ }
155
+
156
+ export function searchIndexMain(argv = process.argv.slice(2)) {
157
+ const outIdx = argv.indexOf('--out');
158
+ const extraIdx = argv.indexOf('--extra-html');
159
+ const outFile = outIdx !== -1 && argv[outIdx + 1] ? argv[outIdx + 1] : join(PKG_ROOT, 'dist', 'search-index.json');
160
+ const extra = extraIdx !== -1 && argv[extraIdx + 1] ? argv[extraIdx + 1].split(',') : [];
161
+ const result = buildSearchIndex({ outFile, extraHtmlDirs: extra });
162
+ console.log(`Wrote ${result.count} search entries → ${result.outFile}`);
163
+ }
@@ -18,7 +18,9 @@ function varName(key) {
18
18
  function emitBlock(indent, obj) {
19
19
  if (!obj || typeof obj !== 'object') return [];
20
20
  const lines = [];
21
- for (const [k, v] of Object.entries(obj)) {
21
+ const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b));
22
+ for (const k of keys) {
23
+ const v = obj[k];
22
24
  if (typeof v === 'string') {
23
25
  lines.push(`${indent}${varName(k)}: ${v};`);
24
26
  }
@@ -62,6 +64,24 @@ export function buildTokensFromJson(filePath) {
62
64
  lines.push(' }');
63
65
  }
64
66
 
67
+ if (data.fonts && typeof data.fonts === 'object') {
68
+ lines.push(' :root {');
69
+ lines.push(...emitBlock(' ', data.fonts));
70
+ lines.push(' }');
71
+ }
72
+
73
+ if (data.motion && typeof data.motion === 'object') {
74
+ lines.push(' :root {');
75
+ lines.push(...emitBlock(' ', data.motion));
76
+ lines.push(' }');
77
+ }
78
+
79
+ if (data.zIndex && typeof data.zIndex === 'object') {
80
+ lines.push(' :root {');
81
+ lines.push(...emitBlock(' ', data.zIndex));
82
+ lines.push(' }');
83
+ }
84
+
65
85
  lines.push('}');
66
86
  return `${lines.join('\n')}\n`;
67
87
  }
@@ -0,0 +1,69 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+
3
+ const OKLCH_RE = /^oklch\(\s*[\d.]+%?\s+[\d.]+\s+[\d.]+(?:\s*\/\s*[\d.]+%?)?\s*\)$/i;
4
+
5
+ export function validateTokensJson(filePath) {
6
+ if (!existsSync(filePath)) {
7
+ return { ok: false, errors: [`File not found: ${filePath}`] };
8
+ }
9
+ let data;
10
+ try {
11
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
12
+ } catch (e) {
13
+ return { ok: false, errors: [`Invalid JSON: ${e.message}`] };
14
+ }
15
+
16
+ const errors = [];
17
+ const tokens = data.tokens ?? data;
18
+
19
+ if (typeof tokens !== 'object' || tokens === null) {
20
+ return { ok: false, errors: ['Root must be an object'] };
21
+ }
22
+
23
+ const walk = (obj, path) => {
24
+ for (const [key, val] of Object.entries(obj)) {
25
+ if (key === 'themes' || key === 'displayP3' || key === 'fonts' || key === 'motion' || key === 'zIndex') continue;
26
+ if (typeof val === 'object' && val !== null) {
27
+ walk(val, `${path}.${key}`);
28
+ continue;
29
+ }
30
+ if (typeof val !== 'string') {
31
+ errors.push(`${path}.${key}: must be a string`);
32
+ continue;
33
+ }
34
+ if (key.includes('color') && val.startsWith('oklch') && !OKLCH_RE.test(val.trim())) {
35
+ errors.push(`${path}.${key}: invalid OKLCH syntax`);
36
+ }
37
+ }
38
+ };
39
+
40
+ walk(tokens, 'tokens');
41
+
42
+ if (data.themes && typeof data.themes === 'object') {
43
+ for (const name of Object.keys(data.themes)) {
44
+ if (!/^[a-z0-9-]+$/i.test(name)) {
45
+ errors.push(`themes.${name}: invalid theme name`);
46
+ }
47
+ }
48
+ }
49
+
50
+ if (data.fonts && typeof data.fonts === 'object') {
51
+ for (const [k, v] of Object.entries(data.fonts)) {
52
+ if (typeof v !== 'string') errors.push(`fonts.${k}: must be a string`);
53
+ }
54
+ }
55
+
56
+ if (data.motion && typeof data.motion === 'object') {
57
+ for (const [k, v] of Object.entries(data.motion)) {
58
+ if (typeof v !== 'string') errors.push(`motion.${k}: must be a string`);
59
+ }
60
+ }
61
+
62
+ if (data.zIndex && typeof data.zIndex === 'object') {
63
+ for (const [k, v] of Object.entries(data.zIndex)) {
64
+ if (typeof v !== 'string') errors.push(`zIndex.${k}: must be a string`);
65
+ }
66
+ }
67
+
68
+ return { ok: errors.length === 0, errors, data };
69
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Optional accessibility bootstrap for VelinStyle apps.
3
+ * @example
4
+ * import { initA11y } from '@birdapi/velinstyle/a11y';
5
+ * initA11y({ announcer: true, scrollPadding: true });
6
+ */
7
+
8
+ import { getAnnouncer } from './a11y-utils.js';
9
+
10
+ /**
11
+ * @param {{ announcer?: boolean, scrollPadding?: boolean, skipLink?: boolean }} [options]
12
+ */
13
+ export function initA11y(options = {}) {
14
+ if (typeof document === 'undefined') return;
15
+
16
+ const {
17
+ announcer = true,
18
+ scrollPadding = true,
19
+ skipLink = false,
20
+ } = options;
21
+
22
+ if (announcer) getAnnouncer();
23
+
24
+ if (scrollPadding) {
25
+ const nav = document.querySelector('.velin-nav, .velin-doc-header, .site-nav, [data-velin-fixed-nav]');
26
+ if (nav) {
27
+ const h = nav.getBoundingClientRect().height;
28
+ document.documentElement.style.setProperty('--velin-nav-height', `${Math.ceil(h)}px`);
29
+ document.documentElement.classList.add('velin-scroll-pt-nav');
30
+ }
31
+ }
32
+
33
+ if (skipLink && !document.querySelector('.velin-skip-link') && document.getElementById('main')) {
34
+ const a = document.createElement('a');
35
+ a.href = '#main';
36
+ a.className = 'velin-skip-link';
37
+ a.textContent = 'Skip to main content';
38
+ document.body.prepend(a);
39
+ }
40
+ }
41
+
42
+ export { announce, getAnnouncer } from './a11y-utils.js';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared accessibility helpers for VelinStyle web components.
3
+ */
4
+
5
+ let _announcer;
6
+
7
+ export function getAnnouncer() {
8
+ if (typeof document === 'undefined') return null;
9
+ if (_announcer?.isConnected) return _announcer;
10
+ _announcer = document.querySelector('velin-announcer');
11
+ if (!_announcer) {
12
+ _announcer = document.createElement('velin-announcer');
13
+ document.body.appendChild(_announcer);
14
+ }
15
+ return _announcer;
16
+ }
17
+
18
+ /**
19
+ * @param {string} message
20
+ * @param {'polite' | 'assertive'} [priority]
21
+ */
22
+ export function announce(message, priority = 'polite') {
23
+ const el = getAnnouncer();
24
+ if (!el || typeof el.announce !== 'function') return;
25
+ el.announce(message, { assertive: priority === 'assertive' });
26
+ }
27
+
28
+ /**
29
+ * @param {HTMLElement} trigger
30
+ * @param {HTMLElement | null} panel
31
+ */
32
+ export function syncExpanded(trigger, panel) {
33
+ if (!trigger || !panel) return;
34
+ const open = trigger.getAttribute('aria-expanded') === 'true';
35
+ panel.hidden = !open;
36
+ if (panel.id) trigger.setAttribute('aria-controls', panel.id);
37
+ }
38
+
39
+ /**
40
+ * @param {HTMLElement} el
41
+ * @param {string} label
42
+ */
43
+ export function ensureAriaLabel(el, label) {
44
+ if (!el || !label) return;
45
+ if (!el.getAttribute('aria-label')?.trim()) el.setAttribute('aria-label', label);
46
+ }
47
+
48
+ /**
49
+ * @param {() => void} fn
50
+ * @param {() => void} [fallback]
51
+ */
52
+ export function respectReducedMotion(fn, fallback) {
53
+ const reduced = typeof matchMedia === 'function'
54
+ && matchMedia('(prefers-reduced-motion: reduce)').matches;
55
+ if (reduced && fallback) fallback();
56
+ else fn();
57
+ }
58
+
59
+ /**
60
+ * @param {string} [status] live|idle|error
61
+ */
62
+ export function liveDotLabel(status) {
63
+ const map = {
64
+ live: 'Live',
65
+ idle: 'Idle',
66
+ error: 'Error',
67
+ offline: 'Offline',
68
+ connecting: 'Connecting',
69
+ };
70
+ return map[String(status || 'live').toLowerCase()] || status || 'Live';
71
+ }