@kerfjs/ui 5.0.0-beta.17 → 5.0.0-beta.21

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 (274) hide show
  1. package/README.md +221 -81
  2. package/ai/application-ui-diagnostic-ids-v1.json +92 -0
  3. package/ai/application-ui-profile-sync.cjs +529 -0
  4. package/ai/application-ui-profile.d.mts +1 -0
  5. package/ai/application-ui-profile.d.ts +102 -0
  6. package/ai/application-ui-profile.defaults.json +41 -0
  7. package/ai/application-ui-profile.mjs +626 -0
  8. package/ai/application-ui-profile.schema.json +130 -0
  9. package/ai/catalog-authoring.json +25 -0
  10. package/ai/catalog-authoring.schema.json +61 -0
  11. package/ai/compile-time-contracts-v1.json +101 -0
  12. package/ai/compile-time-contracts-v1.schema.json +49 -0
  13. package/ai/component-catalog-extension-v2.d.ts +7 -0
  14. package/ai/component-catalog-extension-v2.schema.json +7 -0
  15. package/ai/component-catalog-extension.schema.json +79 -0
  16. package/ai/component-catalog-v2-overrides.json +326 -0
  17. package/ai/component-catalog-v2.d.ts +67 -0
  18. package/ai/component-catalog-v2.json +4441 -0
  19. package/ai/component-catalog-v2.schema.json +246 -0
  20. package/ai/component-catalog.json +649 -6
  21. package/ai/component-catalog.schema.json +15 -0
  22. package/ai/public-api-signatures-v1.md +357 -91
  23. package/ai/skill.md +95 -50
  24. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  25. package/analyzer/cli.mjs +46 -0
  26. package/analyzer/index.d.mts +45 -0
  27. package/analyzer/index.mjs +743 -0
  28. package/analyzer/report.schema.json +66 -0
  29. package/dist/app-tab.d.ts +1 -1
  30. package/dist/app-tab.js +1 -1
  31. package/dist/browser/pane.js +3 -0
  32. package/dist/browser/sunken-panel.js +3 -0
  33. package/dist/catalog-resources.d.ts +34 -0
  34. package/dist/catalog-resources.js +22 -0
  35. package/dist/catalog-resources.js.map +1 -0
  36. package/dist/catalog.d.ts +29 -4
  37. package/dist/catalog.js +302 -84
  38. package/dist/catalog.js.map +1 -1
  39. package/dist/chunk-5VTB2BKP.js +35 -0
  40. package/dist/chunk-5VTB2BKP.js.map +1 -0
  41. package/dist/chunk-6WTETUZ6.js +21 -0
  42. package/dist/chunk-6WTETUZ6.js.map +1 -0
  43. package/dist/chunk-7KEUJIIC.js +218 -0
  44. package/dist/chunk-7KEUJIIC.js.map +1 -0
  45. package/dist/chunk-7L6EHUMV.js +31 -0
  46. package/dist/chunk-7L6EHUMV.js.map +1 -0
  47. package/dist/{chunk-XXMOARTH.js → chunk-AASLYRGE.js} +57 -16
  48. package/dist/chunk-AASLYRGE.js.map +1 -0
  49. package/dist/chunk-BBRXX6PF.js +108 -0
  50. package/dist/chunk-BBRXX6PF.js.map +1 -0
  51. package/dist/{chunk-6NLRBSVX.js → chunk-CN2XNTAM.js} +129 -72
  52. package/dist/chunk-CN2XNTAM.js.map +1 -0
  53. package/dist/chunk-DPDBVKNA.js +113 -0
  54. package/dist/chunk-DPDBVKNA.js.map +1 -0
  55. package/dist/chunk-DSNIYOZ3.js +114 -0
  56. package/dist/chunk-DSNIYOZ3.js.map +1 -0
  57. package/dist/chunk-ELSJX4W7.js +68 -0
  58. package/dist/chunk-ELSJX4W7.js.map +1 -0
  59. package/dist/{chunk-AHQI52QO.js → chunk-FEY65TBV.js} +6 -2
  60. package/dist/chunk-FEY65TBV.js.map +1 -0
  61. package/dist/chunk-JRKSK2HX.js +79 -0
  62. package/dist/chunk-JRKSK2HX.js.map +1 -0
  63. package/dist/chunk-K57KRAOV.js +117 -0
  64. package/dist/chunk-K57KRAOV.js.map +1 -0
  65. package/dist/chunk-LPOMRC4C.js +25 -0
  66. package/dist/chunk-LPOMRC4C.js.map +1 -0
  67. package/dist/chunk-M7JBER5U.js +41 -0
  68. package/dist/chunk-M7JBER5U.js.map +1 -0
  69. package/dist/chunk-NENBUYX5.js +79 -0
  70. package/dist/chunk-NENBUYX5.js.map +1 -0
  71. package/dist/chunk-OHU6CI22.js +83 -0
  72. package/dist/chunk-OHU6CI22.js.map +1 -0
  73. package/dist/{chunk-YGN7OMLT.js → chunk-OZWU5ZPZ.js} +2 -2
  74. package/dist/chunk-OZWU5ZPZ.js.map +1 -0
  75. package/dist/chunk-PIDTEQ3U.js +105 -0
  76. package/dist/chunk-PIDTEQ3U.js.map +1 -0
  77. package/dist/chunk-PX3YSS5C.js +32 -0
  78. package/dist/chunk-PX3YSS5C.js.map +1 -0
  79. package/dist/chunk-PZQVSDLH.js +61 -0
  80. package/dist/chunk-PZQVSDLH.js.map +1 -0
  81. package/dist/chunk-QO55FN2Y.js +20 -0
  82. package/dist/chunk-QO55FN2Y.js.map +1 -0
  83. package/dist/chunk-QQFJMRIB.js +45 -0
  84. package/dist/chunk-QQFJMRIB.js.map +1 -0
  85. package/dist/chunk-RYRIFZCT.js +58 -0
  86. package/dist/chunk-RYRIFZCT.js.map +1 -0
  87. package/dist/{chunk-7BR3DO2A.js → chunk-SRSJO5QE.js} +13 -4
  88. package/dist/chunk-SRSJO5QE.js.map +1 -0
  89. package/dist/chunk-VLYV65LK.js +38 -0
  90. package/dist/chunk-VLYV65LK.js.map +1 -0
  91. package/dist/chunk-W4CXQYSG.js +23 -0
  92. package/dist/chunk-W4CXQYSG.js.map +1 -0
  93. package/dist/{chunk-WXMGNX2Y.js → chunk-W7YD6E4N.js} +2 -2
  94. package/dist/chunk-W7YD6E4N.js.map +1 -0
  95. package/dist/{chunk-56RJXPHG.js → chunk-Y6LYKFXY.js} +1 -1
  96. package/dist/{chunk-56RJXPHG.js.map → chunk-Y6LYKFXY.js.map} +1 -1
  97. package/dist/collapsible-panel.d.ts +2 -2
  98. package/dist/collapsible-panel.js +47 -6
  99. package/dist/collapsible-panel.js.map +1 -1
  100. package/dist/device-class.js +33 -5
  101. package/dist/device-class.js.map +1 -1
  102. package/dist/disclosure-arrow.js +1 -1
  103. package/dist/empty-state.d.ts +1 -1
  104. package/dist/empty-state.js +1 -1
  105. package/dist/floating-toolbar.d.ts +1 -1
  106. package/dist/floating-toolbar.js +1 -1
  107. package/dist/index.d.ts +6 -4
  108. package/dist/index.js +27 -25
  109. package/dist/list-action-row.d.ts +1 -1
  110. package/dist/list-action-row.js +1 -1
  111. package/dist/list-header.d.ts +30 -9
  112. package/dist/list-header.js +1 -1
  113. package/dist/list-inset-control.d.ts +1 -1
  114. package/dist/list-inset-control.js +1 -1
  115. package/dist/list-inset-text.d.ts +1 -1
  116. package/dist/list-inset-text.js +1 -1
  117. package/dist/list-item.d.ts +1 -1
  118. package/dist/list-item.js +1 -1
  119. package/dist/loading-spinner.js +1 -1
  120. package/dist/nav-stack.d.ts +1 -1
  121. package/dist/nav-stack.js +1 -1
  122. package/dist/pane.d.ts +49 -0
  123. package/dist/pane.js +4 -0
  124. package/dist/pane.js.map +1 -0
  125. package/dist/panel-header.d.ts +10 -4
  126. package/dist/panel-header.js +1 -1
  127. package/dist/resizable-region.d.ts +1 -1
  128. package/dist/resizable-region.js +1 -1
  129. package/dist/segmented-control.d.ts +6 -6
  130. package/dist/segmented-control.js +1 -1
  131. package/dist/select.d.ts +11 -5
  132. package/dist/select.js +1 -1
  133. package/dist/skeleton.d.ts +1 -1
  134. package/dist/skeleton.js +1 -1
  135. package/dist/split-view.d.ts +1 -1
  136. package/dist/split-view.js +70 -10
  137. package/dist/split-view.js.map +1 -1
  138. package/dist/state-banner.d.ts +4 -3
  139. package/dist/state-banner.js +1 -1
  140. package/dist/styles/catalog.css +32 -6
  141. package/dist/styles/layout.css +2 -20
  142. package/dist/styles/pane.css +62 -0
  143. package/dist/styles/segmented-control.css +3 -7
  144. package/dist/styles/sunken-panel.css +18 -0
  145. package/dist/sunken-panel.d.ts +16 -0
  146. package/dist/sunken-panel.js +3 -0
  147. package/dist/sunken-panel.js.map +1 -0
  148. package/dist/tab-bar.d.ts +4 -3
  149. package/dist/tab-bar.js +1 -1
  150. package/dist/tab-scaffold.d.ts +6 -6
  151. package/dist/tab-scaffold.js +43 -8
  152. package/dist/tab-scaffold.js.map +1 -1
  153. package/dist/token-search-field.d.ts +25 -9
  154. package/dist/token-search-field.js +1 -1
  155. package/dist/toolbar-control-group.d.ts +10 -6
  156. package/dist/toolbar-control-group.js +1 -1
  157. package/dist/toolbar-text.d.ts +10 -11
  158. package/dist/toolbar-text.js +1 -1
  159. package/dist/toolbar.d.ts +1 -1
  160. package/dist/toolbar.js +1 -1
  161. package/dist/value-table.d.ts +2 -2
  162. package/dist/value-table.js +1 -1
  163. package/dist/wire-catalog.d.ts +31 -2
  164. package/dist/wire-catalog.js +203 -8
  165. package/dist/wire-catalog.js.map +1 -1
  166. package/dist/wire-nav-stack.js +22 -7
  167. package/dist/wire-nav-stack.js.map +1 -1
  168. package/dist/wire-resizable-regions.d.ts +1 -1
  169. package/dist/wire-resizable-regions.js +1 -1
  170. package/dist/wire-sidebar.js +52 -41
  171. package/dist/wire-sidebar.js.map +1 -1
  172. package/dist/wire-tab-bars.d.ts +5 -2
  173. package/dist/wire-tab-bars.js +1 -1
  174. package/dist/wire-tab-scaffold.js +9 -4
  175. package/dist/wire-tab-scaffold.js.map +1 -1
  176. package/dist/wire-token-search-fields.d.ts +12 -7
  177. package/dist/wire-token-search-fields.js +1 -1
  178. package/dist/workbench.d.ts +1 -1
  179. package/dist/workbench.js +56 -9
  180. package/dist/workbench.js.map +1 -1
  181. package/docs/app-layouts.md +7 -7
  182. package/docs/catalog.md +249 -46
  183. package/docs/collapsible-panel.md +38 -11
  184. package/docs/component-contract.md +132 -7
  185. package/docs/component-selection.md +79 -67
  186. package/docs/design/templates.md +1 -1
  187. package/docs/design-philosophy.md +7 -9
  188. package/docs/device-class.md +8 -8
  189. package/docs/examples/application-ui-profile.json +60 -0
  190. package/docs/examples/command-palette-adapter.tsx +67 -17
  191. package/docs/examples/component-catalog-extension-v2.json +81 -0
  192. package/docs/examples/component-catalog-extension.json +45 -0
  193. package/docs/layout.md +51 -32
  194. package/docs/nav-stack.md +15 -6
  195. package/docs/recipes.md +4 -4
  196. package/docs/split-view.md +3 -3
  197. package/docs/sunken-panel.md +43 -0
  198. package/docs/tab-scaffold.md +16 -7
  199. package/docs/type-contracts.md +99 -0
  200. package/docs/ui-analyzer.md +81 -0
  201. package/docs/ui-doctor.md +77 -0
  202. package/docs/ui-evaluator.md +122 -0
  203. package/docs/webawesome-theme.md +8 -8
  204. package/docs/workbench.md +18 -5
  205. package/doctor/cli.mjs +115 -0
  206. package/doctor/config.schema.json +68 -0
  207. package/doctor/index.d.mts +116 -0
  208. package/doctor/index.mjs +1256 -0
  209. package/doctor/report.schema.json +118 -0
  210. package/evaluator/cli.mjs +89 -0
  211. package/evaluator/index.d.mts +90 -0
  212. package/evaluator/index.mjs +1138 -0
  213. package/evaluator/report.schema.json +152 -0
  214. package/llms.txt +58 -15
  215. package/package.json +70 -10
  216. package/ux-demo/recipes/app-shell.tsx +206 -10
  217. package/ux-demo/recipes/collapsible-sidebar.tsx +167 -44
  218. package/ux-demo/recipes/compact-toolbar.tsx +114 -12
  219. package/ux-demo/recipes/composer-form.tsx +106 -18
  220. package/ux-demo/recipes/list-detail-dialog.tsx +144 -8
  221. package/ux-demo/recipes/list-workspace-states.tsx +165 -8
  222. package/ux-demo/recipes/loaders.ts +5 -2
  223. package/ux-demo/recipes/loading-inspector.tsx +124 -41
  224. package/ux-demo/recipes/mount-recipe.ts +27 -6
  225. package/ux-demo/recipes/navigation-sidebar.tsx +120 -9
  226. package/ux-demo/recipes/navigation-stack.tsx +61 -19
  227. package/ux-demo/recipes/types.ts +3 -1
  228. package/ux-demo/recipes/workspace-header.tsx +57 -2
  229. package/dist/chunk-2Z5TWHKQ.js +0 -34
  230. package/dist/chunk-2Z5TWHKQ.js.map +0 -1
  231. package/dist/chunk-37T3VNIZ.js +0 -19
  232. package/dist/chunk-37T3VNIZ.js.map +0 -1
  233. package/dist/chunk-55DATV3A.js +0 -10
  234. package/dist/chunk-55DATV3A.js.map +0 -1
  235. package/dist/chunk-5ATCDSQS.js +0 -42
  236. package/dist/chunk-5ATCDSQS.js.map +0 -1
  237. package/dist/chunk-5E2HVJ33.js +0 -10
  238. package/dist/chunk-5E2HVJ33.js.map +0 -1
  239. package/dist/chunk-6NLRBSVX.js.map +0 -1
  240. package/dist/chunk-7BR3DO2A.js.map +0 -1
  241. package/dist/chunk-7JB7VPRI.js +0 -34
  242. package/dist/chunk-7JB7VPRI.js.map +0 -1
  243. package/dist/chunk-7LDCTU7D.js +0 -134
  244. package/dist/chunk-7LDCTU7D.js.map +0 -1
  245. package/dist/chunk-AHQI52QO.js.map +0 -1
  246. package/dist/chunk-AQNKJRFH.js +0 -79
  247. package/dist/chunk-AQNKJRFH.js.map +0 -1
  248. package/dist/chunk-AVZHJN6C.js +0 -14
  249. package/dist/chunk-AVZHJN6C.js.map +0 -1
  250. package/dist/chunk-B24C4A6T.js +0 -22
  251. package/dist/chunk-B24C4A6T.js.map +0 -1
  252. package/dist/chunk-CH4SQ6H5.js +0 -39
  253. package/dist/chunk-CH4SQ6H5.js.map +0 -1
  254. package/dist/chunk-DAFFDO33.js +0 -14
  255. package/dist/chunk-DAFFDO33.js.map +0 -1
  256. package/dist/chunk-I7INRJO4.js +0 -17
  257. package/dist/chunk-I7INRJO4.js.map +0 -1
  258. package/dist/chunk-L4OKFDJY.js +0 -24
  259. package/dist/chunk-L4OKFDJY.js.map +0 -1
  260. package/dist/chunk-M5JRI6MN.js +0 -27
  261. package/dist/chunk-M5JRI6MN.js.map +0 -1
  262. package/dist/chunk-RNPGFQ33.js +0 -10
  263. package/dist/chunk-RNPGFQ33.js.map +0 -1
  264. package/dist/chunk-VPCTCUED.js +0 -10
  265. package/dist/chunk-VPCTCUED.js.map +0 -1
  266. package/dist/chunk-WRTAQ7BZ.js +0 -23
  267. package/dist/chunk-WRTAQ7BZ.js.map +0 -1
  268. package/dist/chunk-WVPYVDXY.js +0 -15
  269. package/dist/chunk-WVPYVDXY.js.map +0 -1
  270. package/dist/chunk-WXMGNX2Y.js.map +0 -1
  271. package/dist/chunk-XXMOARTH.js.map +0 -1
  272. package/dist/chunk-YGN7OMLT.js.map +0 -1
  273. package/dist/chunk-ZQQ6P4SV.js +0 -20
  274. package/dist/chunk-ZQQ6P4SV.js.map +0 -1
@@ -0,0 +1,1138 @@
1
+ /* global CSS, Element, HTMLElement, Node, document, getComputedStyle, innerHeight, innerWidth, location, window */
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, relative, resolve } from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ import { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
9
+
10
+ export const UI_EVALUATION_SCHEMA_VERSION = 1;
11
+
12
+ export const UI_EVALUATION_RULES = Object.freeze({
13
+ 'KUI-B001': {
14
+ severity: 'error',
15
+ title: 'Browser context could not be evaluated',
16
+ },
17
+ 'KUI-B010': { severity: 'error', title: 'Horizontal page overflow' },
18
+ 'KUI-B011': { severity: 'error', title: 'Clipped interactive control' },
19
+ 'KUI-B012': { severity: 'error', title: 'Obscured interactive control' },
20
+ 'KUI-B020': { severity: 'error', title: 'Invalid positive tab order' },
21
+ 'KUI-B021': { severity: 'error', title: 'Focused control is not visible' },
22
+ 'KUI-B022': {
23
+ severity: 'error',
24
+ title: 'Custom interactive role is not keyboard reachable',
25
+ },
26
+ 'KUI-B023': { severity: 'error', title: 'Keyboard action did not operate' },
27
+ 'KUI-B030': { severity: 'error', title: 'Interactive control has no name' },
28
+ 'KUI-B040': { severity: 'error', title: 'Insufficient text contrast' },
29
+ 'KUI-B050': {
30
+ severity: 'error',
31
+ title: 'Interactive hit target is too small',
32
+ },
33
+ 'KUI-B060': { severity: 'error', title: 'Competing scroll owners' },
34
+ 'KUI-B070': { severity: 'error', title: 'Shared alignment edge drift' },
35
+ 'KUI-B080': { severity: 'error', title: 'Runtime geometry ownership drift' },
36
+ });
37
+
38
+ export const SUBJECTIVE_REVIEW_RUBRIC = Object.freeze([
39
+ {
40
+ id: 'hierarchy',
41
+ prompt:
42
+ '0 unclear scope; 1 usable with competing emphasis; 2 clear application, page, and section priority.',
43
+ },
44
+ {
45
+ id: 'rhythm',
46
+ prompt:
47
+ '0 arbitrary or doubled spacing; 1 isolated inconsistencies; 2 one coherent semantic spacing system.',
48
+ },
49
+ {
50
+ id: 'density',
51
+ prompt:
52
+ '0 clipped or undersized controls; 1 usable but uneven; 2 compact and consistently operable.',
53
+ },
54
+ {
55
+ id: 'alignment',
56
+ prompt:
57
+ '0 visibly broken columns or edges; 1 minor drift; 2 shared columns and intentional edges.',
58
+ },
59
+ {
60
+ id: 'scroll-ownership',
61
+ prompt:
62
+ '0 competing or misplaced scroll regions; 1 usable with an awkward boundary; 2 one obvious scroll owner per pane.',
63
+ },
64
+ {
65
+ id: 'aesthetic-fit',
66
+ prompt:
67
+ '0 visually conflicts with the product; 1 broadly coherent with isolated mismatches; 2 intentionally fits the product and Kerf language.',
68
+ },
69
+ ]);
70
+
71
+ const baseContexts = [
72
+ { id: 'wide-light', width: 1440, height: 900, zoom: 1, colorScheme: 'light' },
73
+ {
74
+ id: 'intermediate-light',
75
+ width: 900,
76
+ height: 768,
77
+ zoom: 1,
78
+ colorScheme: 'light',
79
+ },
80
+ {
81
+ id: 'narrow-light',
82
+ width: 390,
83
+ height: 844,
84
+ zoom: 1,
85
+ colorScheme: 'light',
86
+ },
87
+ {
88
+ id: 'zoom-200-light',
89
+ width: 720,
90
+ height: 450,
91
+ zoom: 2,
92
+ colorScheme: 'light',
93
+ },
94
+ { id: 'wide-dark', width: 1440, height: 900, zoom: 1, colorScheme: 'dark' },
95
+ {
96
+ id: 'wide-reduced-motion',
97
+ width: 1440,
98
+ height: 900,
99
+ zoom: 1,
100
+ colorScheme: 'light',
101
+ reducedMotion: 'reduce',
102
+ },
103
+ ];
104
+
105
+ export function buildEvaluationContexts(overrides) {
106
+ const contexts = overrides ?? baseContexts;
107
+ return contexts.map((context) => ({
108
+ reducedMotion: 'no-preference',
109
+ ...context,
110
+ }));
111
+ }
112
+
113
+ function parseColor(color) {
114
+ const values = color?.match(/[\d.]+/g)?.map(Number);
115
+ if (!values || values.length < 3) return null;
116
+ return [...values.slice(0, 3), values[3] ?? 1];
117
+ }
118
+
119
+ function composite([red, green, blue, alpha = 1], background) {
120
+ return [
121
+ red * alpha + background[0] * (1 - alpha),
122
+ green * alpha + background[1] * (1 - alpha),
123
+ blue * alpha + background[2] * (1 - alpha),
124
+ ];
125
+ }
126
+
127
+ function luminance(rgb) {
128
+ const channels = rgb.map((channel) => {
129
+ const value = channel / 255;
130
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
131
+ });
132
+ return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
133
+ }
134
+
135
+ export function contrastRatio(foreground, background) {
136
+ const foregroundColor = Array.isArray(foreground)
137
+ ? foreground
138
+ : parseColor(foreground);
139
+ const backgroundRgb = Array.isArray(background)
140
+ ? background
141
+ : parseColor(background);
142
+ if (!foregroundColor || !backgroundRgb) return null;
143
+ const renderedBackground = composite(backgroundRgb, [255, 255, 255]);
144
+ const foregroundRgb = composite(foregroundColor, renderedBackground);
145
+ const values = [luminance(foregroundRgb), luminance(renderedBackground)].sort(
146
+ (left, right) => right - left,
147
+ );
148
+ return (values[0] + 0.05) / (values[1] + 0.05);
149
+ }
150
+
151
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
152
+
153
+ function cancellationError() {
154
+ return Object.assign(new Error('Kerf UI evaluation was cancelled.'), {
155
+ name: 'AbortError',
156
+ });
157
+ }
158
+
159
+ function abortable(value, signal) {
160
+ if (!signal) return value;
161
+ if (signal.aborted) return Promise.reject(cancellationError());
162
+ return new Promise((resolvePromise, rejectPromise) => {
163
+ const abort = () => rejectPromise(cancellationError());
164
+ signal.addEventListener('abort', abort, { once: true });
165
+ Promise.resolve(value).then(
166
+ (result) => {
167
+ signal.removeEventListener('abort', abort);
168
+ resolvePromise(result);
169
+ },
170
+ (error) => {
171
+ signal.removeEventListener('abort', abort);
172
+ rejectPromise(error);
173
+ },
174
+ );
175
+ });
176
+ }
177
+
178
+ async function loadGeometryContracts(profileResult) {
179
+ const contracts = [];
180
+ for (const catalog of profileResult.profile?.catalogs ?? []) {
181
+ const owner = profileResult.provenance?.[`$catalogs.${catalog.package}`];
182
+ if (!owner) continue;
183
+ try {
184
+ const artifact = JSON.parse(
185
+ await readFile(
186
+ resolve(dirname(owner), catalog.composition.path),
187
+ 'utf8',
188
+ ),
189
+ );
190
+ for (const entry of artifact.entries ?? []) {
191
+ const className = entry.boundaries?.rootClass;
192
+ if (className)
193
+ contracts.push({
194
+ key: entry.key ?? `${artifact.package}:${entry.id}`,
195
+ className,
196
+ geometry: entry.layout?.geometry,
197
+ });
198
+ }
199
+ } catch {
200
+ // Profile loading already emits source-located catalog diagnostics.
201
+ }
202
+ }
203
+ return contracts;
204
+ }
205
+
206
+ function reportDiagnostic(code, context, item = {}) {
207
+ const rule = UI_EVALUATION_RULES[code];
208
+ const diagnostic = {
209
+ code,
210
+ severity: rule.severity,
211
+ context,
212
+ message: item.message ?? rule.title,
213
+ };
214
+ for (const property of ['repair', 'selector', 'evidence'])
215
+ if (item[property] !== undefined) diagnostic[property] = item[property];
216
+ return diagnostic;
217
+ }
218
+
219
+ function summarize(diagnostics) {
220
+ return {
221
+ errors: diagnostics.filter((item) => item.severity === 'error').length,
222
+ warnings: diagnostics.filter((item) => item.severity === 'warning').length,
223
+ passed: diagnostics.every((item) => item.severity !== 'error'),
224
+ };
225
+ }
226
+
227
+ async function inspectPage(page, geometryContracts) {
228
+ return page.evaluate((contracts) => {
229
+ const diagnostics = [];
230
+ const repair = {
231
+ overflow:
232
+ 'Remove fixed inline sizing or assign horizontal scrolling to one documented owner.',
233
+ clipping:
234
+ 'Move the control into the visible flow or make its documented scroll owner reachable.',
235
+ focus:
236
+ 'Use native controls in DOM order and retain a visible :focus-visible indicator.',
237
+ name: 'Provide visible text, aria-label, or a valid aria-labelledby relationship.',
238
+ contrast:
239
+ 'Use Kerf semantic foreground/background tokens with WCAG AA contrast.',
240
+ target: 'Keep interactive controls at least 44 by 44 CSS pixels.',
241
+ scroll: 'Keep exactly one scroll owner per pane.',
242
+ alignment:
243
+ 'Use the shared public layout/content classes so aligned edges use one inset.',
244
+ geometry:
245
+ 'Remove geometry from the component root and leave it on the catalog-declared owner.',
246
+ };
247
+ const visible = (element) => {
248
+ const style = getComputedStyle(element);
249
+ const rect = element.getBoundingClientRect();
250
+ return (
251
+ style.display !== 'none' &&
252
+ style.visibility !== 'hidden' &&
253
+ Number(style.opacity) !== 0 &&
254
+ rect.width > 0 &&
255
+ rect.height > 0
256
+ );
257
+ };
258
+ const selector = (element) => {
259
+ if (element.id) return `#${CSS.escape(element.id)}`;
260
+ const testId = element.getAttribute('data-testid');
261
+ if (testId) return `[data-testid="${CSS.escape(testId)}"]`;
262
+ const classes = [...element.classList].slice(0, 2);
263
+ return `${element.localName}${classes.map((name) => `.${CSS.escape(name)}`).join('')}`;
264
+ };
265
+ const widgetRoles = new Set([
266
+ 'button',
267
+ 'checkbox',
268
+ 'combobox',
269
+ 'gridcell',
270
+ 'link',
271
+ 'menuitem',
272
+ 'menuitemcheckbox',
273
+ 'menuitemradio',
274
+ 'option',
275
+ 'radio',
276
+ 'scrollbar',
277
+ 'searchbox',
278
+ 'slider',
279
+ 'spinbutton',
280
+ 'switch',
281
+ 'tab',
282
+ 'textbox',
283
+ 'treeitem',
284
+ ]);
285
+ const interactives = [
286
+ ...document.querySelectorAll(
287
+ 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role], [tabindex]',
288
+ ),
289
+ ].filter(
290
+ (element) =>
291
+ (element.matches(
292
+ 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [tabindex]',
293
+ ) ||
294
+ widgetRoles.has(element.getAttribute('role'))) &&
295
+ visible(element) &&
296
+ !element.matches(':disabled,[aria-disabled="true"],[inert] *'),
297
+ );
298
+ const interactiveEvidence = [];
299
+
300
+ if (
301
+ document.documentElement.scrollWidth >
302
+ document.documentElement.clientWidth + 1
303
+ )
304
+ diagnostics.push({
305
+ code: 'KUI-B010',
306
+ selector: 'html',
307
+ message: `Page scrollWidth ${document.documentElement.scrollWidth}px exceeds clientWidth ${document.documentElement.clientWidth}px.`,
308
+ repair: repair.overflow,
309
+ evidence: {
310
+ scrollWidth: document.documentElement.scrollWidth,
311
+ clientWidth: document.documentElement.clientWidth,
312
+ },
313
+ });
314
+
315
+ const clippedBy = (element) => {
316
+ const rect = element.getBoundingClientRect();
317
+ let ancestor = element.parentElement;
318
+ while (ancestor) {
319
+ const style = getComputedStyle(ancestor);
320
+ if (
321
+ /hidden|clip/.test(
322
+ `${style.overflow} ${style.overflowX} ${style.overflowY}`,
323
+ )
324
+ ) {
325
+ const bounds = ancestor.getBoundingClientRect();
326
+ if (
327
+ rect.right > bounds.right + 1 ||
328
+ rect.left < bounds.left - 1 ||
329
+ rect.bottom > bounds.bottom + 1 ||
330
+ rect.top < bounds.top - 1
331
+ )
332
+ return selector(ancestor);
333
+ }
334
+ ancestor = ancestor.parentElement;
335
+ }
336
+ return null;
337
+ };
338
+
339
+ for (const element of interactives) {
340
+ const target = selector(element);
341
+ const rect = element.getBoundingClientRect();
342
+ const clippingOwner = clippedBy(element);
343
+ if (clippingOwner)
344
+ diagnostics.push({
345
+ code: 'KUI-B011',
346
+ selector: target,
347
+ message: `Interactive control is clipped by ${clippingOwner}.`,
348
+ repair: repair.clipping,
349
+ evidence: { clippingOwner },
350
+ });
351
+ const outsideViewport =
352
+ rect.right <= 0 ||
353
+ rect.bottom <= 0 ||
354
+ rect.left >= innerWidth ||
355
+ rect.top >= innerHeight;
356
+ if (outsideViewport)
357
+ diagnostics.push({
358
+ code: 'KUI-B011',
359
+ selector: target,
360
+ message: 'Interactive control is entirely outside the viewport.',
361
+ repair: repair.clipping,
362
+ evidence: {
363
+ viewport: { width: innerWidth, height: innerHeight },
364
+ rect: {
365
+ left: rect.left,
366
+ top: rect.top,
367
+ right: rect.right,
368
+ bottom: rect.bottom,
369
+ },
370
+ },
371
+ });
372
+ if (
373
+ rect.left >= 0 &&
374
+ rect.top >= 0 &&
375
+ rect.right <= innerWidth &&
376
+ rect.bottom <= innerHeight
377
+ ) {
378
+ const center = document.elementFromPoint(
379
+ rect.left + rect.width / 2,
380
+ rect.top + rect.height / 2,
381
+ );
382
+ if (center && center !== element && !element.contains(center))
383
+ diagnostics.push({
384
+ code: 'KUI-B012',
385
+ selector: target,
386
+ message: `Interactive control center is covered by ${selector(center)}.`,
387
+ repair: repair.clipping,
388
+ evidence: { coveringElement: selector(center) },
389
+ });
390
+ }
391
+ if (element.tabIndex > 0)
392
+ diagnostics.push({
393
+ code: 'KUI-B020',
394
+ selector: target,
395
+ message: `Positive tabindex=${element.tabIndex} overrides DOM focus order.`,
396
+ repair: repair.focus,
397
+ evidence: { tabIndex: element.tabIndex },
398
+ });
399
+ if (
400
+ widgetRoles.has(element.getAttribute('role')) &&
401
+ !element.matches(
402
+ 'button,a[href],input:not([type="hidden"]),select,textarea,summary',
403
+ ) &&
404
+ element.tabIndex < 0
405
+ )
406
+ diagnostics.push({
407
+ code: 'KUI-B022',
408
+ selector: target,
409
+ message:
410
+ 'Custom interactive role is not in sequential keyboard order.',
411
+ repair: repair.focus,
412
+ evidence: {
413
+ role: element.getAttribute('role'),
414
+ tabIndex: element.tabIndex,
415
+ },
416
+ });
417
+ const visibleText = (root) =>
418
+ [...root.childNodes]
419
+ .map((node) => {
420
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
421
+ if (!(node instanceof Element)) return node.textContent ?? '';
422
+ if (
423
+ node.getAttribute('aria-hidden') === 'true' ||
424
+ getComputedStyle(node).display === 'none' ||
425
+ getComputedStyle(node).visibility === 'hidden'
426
+ )
427
+ return '';
428
+ return visibleText(node);
429
+ })
430
+ .join(' ')
431
+ .replace(/\s+/g, ' ')
432
+ .trim();
433
+ const referencedText = (ids) =>
434
+ ids
435
+ ?.split(/\s+/)
436
+ .map((id) => {
437
+ const reference = document.getElementById(id);
438
+ return reference ? visibleText(reference) : '';
439
+ })
440
+ .join(' ')
441
+ .trim();
442
+ const inputType = element.getAttribute('type')?.toLowerCase();
443
+ const nativeValueName =
444
+ element.localName === 'input' &&
445
+ ['button', 'submit', 'reset'].includes(inputType)
446
+ ? element.value ||
447
+ (inputType === 'submit'
448
+ ? 'Submit'
449
+ : inputType === 'reset'
450
+ ? 'Reset'
451
+ : '')
452
+ : '';
453
+ const name =
454
+ element.getAttribute('aria-label')?.trim() ||
455
+ referencedText(element.getAttribute('aria-labelledby')) ||
456
+ (element.labels
457
+ ? [...element.labels].map(visibleText).join(' ').trim()
458
+ : '') ||
459
+ (inputType === 'image' ? element.getAttribute('alt')?.trim() : '') ||
460
+ nativeValueName ||
461
+ element.getAttribute('alt')?.trim() ||
462
+ visibleText(element) ||
463
+ element.getAttribute('title')?.trim() ||
464
+ '';
465
+ const computed = getComputedStyle(element);
466
+ interactiveEvidence.push({
467
+ selector: target,
468
+ tag: element.localName,
469
+ role: element.getAttribute('role'),
470
+ name,
471
+ rect: {
472
+ x: rect.x,
473
+ y: rect.y,
474
+ width: rect.width,
475
+ height: rect.height,
476
+ },
477
+ style: {
478
+ color: computed.color,
479
+ backgroundColor: computed.backgroundColor,
480
+ outline: computed.outline,
481
+ boxShadow: computed.boxShadow,
482
+ overflowX: computed.overflowX,
483
+ overflowY: computed.overflowY,
484
+ },
485
+ });
486
+ if (!name)
487
+ diagnostics.push({
488
+ code: 'KUI-B030',
489
+ selector: target,
490
+ message: 'Interactive control has no computed text alternative.',
491
+ repair: repair.name,
492
+ });
493
+ if (
494
+ element.matches(
495
+ 'button,input:not([type="hidden"]),select,textarea,[role]',
496
+ ) &&
497
+ (rect.width < 43.5 || rect.height < 43.5)
498
+ )
499
+ diagnostics.push({
500
+ code: 'KUI-B050',
501
+ selector: target,
502
+ message: `Hit target is ${rect.width.toFixed(1)}×${rect.height.toFixed(1)}px; expected at least 44×44px.`,
503
+ repair: repair.target,
504
+ evidence: { width: rect.width, height: rect.height },
505
+ });
506
+ }
507
+
508
+ const parse = (value) => {
509
+ const values = value?.match(/[\d.]+/g)?.map(Number);
510
+ return values?.length >= 3
511
+ ? [...values.slice(0, 3), values[3] ?? 1]
512
+ : null;
513
+ };
514
+ const over = ([red, green, blue, alpha = 1], background) => [
515
+ red * alpha + background[0] * (1 - alpha),
516
+ green * alpha + background[1] * (1 - alpha),
517
+ blue * alpha + background[2] * (1 - alpha),
518
+ ];
519
+ const lum = (rgb) =>
520
+ rgb
521
+ .map((channel) => {
522
+ const value = channel / 255;
523
+ return value <= 0.03928
524
+ ? value / 12.92
525
+ : ((value + 0.055) / 1.055) ** 2.4;
526
+ })
527
+ .reduce(
528
+ (sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index],
529
+ 0,
530
+ );
531
+ for (const element of interactives) {
532
+ const style = getComputedStyle(element);
533
+ const foreground = parse(style.color);
534
+ const ancestors = [];
535
+ let backgroundElement = element;
536
+ while (backgroundElement) {
537
+ ancestors.push(backgroundElement);
538
+ backgroundElement = backgroundElement.parentElement;
539
+ }
540
+ const background = ancestors
541
+ .reverse()
542
+ .map((owner) => parse(getComputedStyle(owner).backgroundColor))
543
+ .filter(Boolean)
544
+ .reduce((result, color) => over(color, result), [255, 255, 255]);
545
+ if (foreground) {
546
+ const renderedForeground = over(foreground, background);
547
+ const values = [lum(renderedForeground), lum(background)].sort(
548
+ (a, b) => b - a,
549
+ );
550
+ const ratio = (values[0] + 0.05) / (values[1] + 0.05);
551
+ const threshold =
552
+ Number.parseFloat(style.fontSize) >= 24 ||
553
+ (Number.parseFloat(style.fontSize) >= 18.66 &&
554
+ Number(style.fontWeight) >= 700)
555
+ ? 3
556
+ : 4.5;
557
+ if (ratio + 0.01 < threshold)
558
+ diagnostics.push({
559
+ code: 'KUI-B040',
560
+ selector: selector(element),
561
+ message: `Text contrast ${ratio.toFixed(2)}:1 is below ${threshold}:1.`,
562
+ repair: repair.contrast,
563
+ evidence: {
564
+ foreground,
565
+ renderedForeground,
566
+ background,
567
+ ratio,
568
+ threshold,
569
+ },
570
+ });
571
+ }
572
+ }
573
+
574
+ const isScrollOwner = (element) => {
575
+ const style = getComputedStyle(element);
576
+ return (
577
+ /auto|scroll/.test(`${style.overflowX} ${style.overflowY}`) &&
578
+ (element.scrollHeight > element.clientHeight + 1 ||
579
+ element.scrollWidth > element.clientWidth + 1)
580
+ );
581
+ };
582
+ for (const scope of document.querySelectorAll(
583
+ '.kui-pane,[data-kui-scroll-scope]',
584
+ )) {
585
+ const owners = [scope, ...scope.querySelectorAll('*')].filter(
586
+ isScrollOwner,
587
+ );
588
+ if (owners.length > 1)
589
+ diagnostics.push({
590
+ code: 'KUI-B060',
591
+ selector: selector(scope),
592
+ message: `Scroll scope contains ${owners.length} active scroll owners.`,
593
+ repair: repair.scroll,
594
+ evidence: { owners: owners.map(selector) },
595
+ });
596
+ }
597
+
598
+ for (const group of document.querySelectorAll('[data-kui-align-group]')) {
599
+ const edges = [...group.querySelectorAll('[data-kui-align-edge]')].map(
600
+ (element) => ({ element, left: element.getBoundingClientRect().left }),
601
+ );
602
+ if (edges.length > 1) {
603
+ const drift =
604
+ Math.max(...edges.map(({ left }) => left)) -
605
+ Math.min(...edges.map(({ left }) => left));
606
+ if (drift > 1)
607
+ diagnostics.push({
608
+ code: 'KUI-B070',
609
+ selector: selector(group),
610
+ message: `Declared shared alignment edges drift by ${drift.toFixed(1)}px.`,
611
+ repair: repair.alignment,
612
+ evidence: {
613
+ edges: edges.map(({ element, left }) => ({
614
+ selector: selector(element),
615
+ left,
616
+ })),
617
+ },
618
+ });
619
+ }
620
+ }
621
+
622
+ const geometryValue = (style, dimension) => {
623
+ const properties =
624
+ dimension === 'margin'
625
+ ? ['marginTop', 'marginRight', 'marginBottom', 'marginLeft']
626
+ : dimension === 'padding'
627
+ ? ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']
628
+ : [
629
+ 'borderTopWidth',
630
+ 'borderRightWidth',
631
+ 'borderBottomWidth',
632
+ 'borderLeftWidth',
633
+ ];
634
+ return properties.reduce(
635
+ (sum, property) => sum + Number.parseFloat(style[property] || '0'),
636
+ 0,
637
+ );
638
+ };
639
+ for (const contract of contracts) {
640
+ for (const element of document.getElementsByClassName(
641
+ contract.className,
642
+ )) {
643
+ if (!visible(element)) continue;
644
+ const style = getComputedStyle(element);
645
+ for (const dimension of ['margin', 'border', 'padding']) {
646
+ const owner = contract.geometry?.[dimension];
647
+ const value = geometryValue(style, dimension);
648
+ if (['none', 'parent', 'child'].includes(owner) && value > 0.5)
649
+ diagnostics.push({
650
+ code: 'KUI-B080',
651
+ selector: selector(element),
652
+ message: `${contract.key} declares ${dimension} owner ${owner}, but its root computes ${value.toFixed(1)}px total ${dimension}.`,
653
+ repair: repair.geometry,
654
+ evidence: {
655
+ key: contract.key,
656
+ className: contract.className,
657
+ dimension,
658
+ owner,
659
+ value,
660
+ },
661
+ });
662
+ }
663
+ }
664
+ }
665
+
666
+ return {
667
+ diagnostics,
668
+ evidence: {
669
+ url: location.href,
670
+ title: document.title,
671
+ interactiveCount: interactives.length,
672
+ elements: interactiveEvidence,
673
+ scrollWidth: document.documentElement.scrollWidth,
674
+ clientWidth: document.documentElement.clientWidth,
675
+ },
676
+ };
677
+ }, geometryContracts);
678
+ }
679
+
680
+ async function inspectFocus(page, limit = 40) {
681
+ const diagnostics = [];
682
+ const sequence = [];
683
+ await page.evaluate(() => {
684
+ const properties = [
685
+ 'outlineColor',
686
+ 'outlineStyle',
687
+ 'outlineWidth',
688
+ 'outlineOffset',
689
+ 'boxShadow',
690
+ 'backgroundColor',
691
+ 'color',
692
+ 'borderTopColor',
693
+ 'borderRightColor',
694
+ 'borderBottomColor',
695
+ 'borderLeftColor',
696
+ 'borderTopWidth',
697
+ 'borderRightWidth',
698
+ 'borderBottomWidth',
699
+ 'borderLeftWidth',
700
+ 'textDecorationColor',
701
+ 'textDecorationLine',
702
+ 'textDecorationThickness',
703
+ ];
704
+ window.__kerfEvaluatorFocusBaseline = {};
705
+ for (const [index, element] of [
706
+ ...document.querySelectorAll(
707
+ 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role], [tabindex]',
708
+ ),
709
+ ].entries()) {
710
+ const id = `kui-focus-${index}`;
711
+ element.setAttribute('data-kui-evaluator-focus-id', id);
712
+ const style = getComputedStyle(element);
713
+ window.__kerfEvaluatorFocusBaseline[id] = Object.fromEntries(
714
+ properties.map((property) => [property, style[property]]),
715
+ );
716
+ }
717
+ if (document.activeElement instanceof HTMLElement)
718
+ document.activeElement.blur();
719
+ });
720
+ try {
721
+ for (let index = 0; index < limit; index += 1) {
722
+ await page.keyboard.press('Tab');
723
+ const state = await page.evaluate(() => {
724
+ const element = document.activeElement;
725
+ if (!(element instanceof HTMLElement) || element === document.body)
726
+ return null;
727
+ const identity = element.getAttribute('data-kui-evaluator-focus-id');
728
+ const rect = element.getBoundingClientRect();
729
+ const style = getComputedStyle(element);
730
+ const baseline = window.__kerfEvaluatorFocusBaseline?.[identity] ?? {};
731
+ const focused = Object.fromEntries(
732
+ Object.keys(baseline).map((property) => [property, style[property]]),
733
+ );
734
+ const changedProperties = Object.keys(baseline).filter(
735
+ (property) => baseline[property] !== focused[property],
736
+ );
737
+ const visualChange =
738
+ (Number.parseFloat(focused.outlineWidth) > 0 &&
739
+ changedProperties.some((property) =>
740
+ property.startsWith('outline'),
741
+ )) ||
742
+ (focused.boxShadow !== 'none' &&
743
+ changedProperties.includes('boxShadow')) ||
744
+ changedProperties.includes('backgroundColor') ||
745
+ changedProperties.includes('color') ||
746
+ ['Top', 'Right', 'Bottom', 'Left'].some(
747
+ (side) =>
748
+ Number.parseFloat(focused[`border${side}Width`]) > 0 &&
749
+ (changedProperties.includes(`border${side}Color`) ||
750
+ changedProperties.includes(`border${side}Width`)),
751
+ ) ||
752
+ (focused.textDecorationLine !== 'none' &&
753
+ changedProperties.some((property) =>
754
+ property.startsWith('textDecoration'),
755
+ ));
756
+ const centerVisible =
757
+ rect.left < innerWidth &&
758
+ rect.top < innerHeight &&
759
+ rect.right > 0 &&
760
+ rect.bottom > 0;
761
+ return {
762
+ identity,
763
+ selector: element.id
764
+ ? `#${CSS.escape(element.id)}`
765
+ : `${element.localName}${[...element.classList]
766
+ .slice(0, 2)
767
+ .map((name) => `.${CSS.escape(name)}`)
768
+ .join('')}`,
769
+ visible:
770
+ rect.width > 0 &&
771
+ rect.height > 0 &&
772
+ centerVisible &&
773
+ style.display !== 'none' &&
774
+ style.visibility !== 'hidden' &&
775
+ Number(style.opacity) !== 0,
776
+ focusVisible: element.matches(':focus-visible') && visualChange,
777
+ changedProperties,
778
+ };
779
+ });
780
+ if (!state || sequence.some((item) => item.identity === state.identity))
781
+ break;
782
+ sequence.push({ identity: state.identity, selector: state.selector });
783
+ if (!state.visible || !state.focusVisible)
784
+ diagnostics.push({
785
+ code: 'KUI-B021',
786
+ selector: state.selector,
787
+ message: !state.visible
788
+ ? 'Sequential focus reached a control outside the visible viewport.'
789
+ : 'Sequential focus has no visual style change from its unfocused state.',
790
+ repair:
791
+ 'Keep focusable controls visible and retain a visible keyboard-focus style change.',
792
+ evidence: state,
793
+ });
794
+ }
795
+ } finally {
796
+ await page.evaluate(() => {
797
+ for (const element of document.querySelectorAll(
798
+ '[data-kui-evaluator-focus-id]',
799
+ ))
800
+ element.removeAttribute('data-kui-evaluator-focus-id');
801
+ delete window.__kerfEvaluatorFocusBaseline;
802
+ });
803
+ }
804
+ return { diagnostics, sequence };
805
+ }
806
+
807
+ async function inspectKeyboardActions(page) {
808
+ const actions = await page.locator('[data-kui-evaluator-action]').count();
809
+ const diagnostics = [];
810
+ const evidence = [];
811
+ for (let index = 0; index < actions; index += 1) {
812
+ const action = page.locator('[data-kui-evaluator-action]').nth(index);
813
+ await action.focus();
814
+ const before = await action.evaluate((element) => ({
815
+ expanded: element.getAttribute('aria-expanded'),
816
+ pressed: element.getAttribute('aria-pressed'),
817
+ checked: element.getAttribute('aria-checked'),
818
+ value: 'value' in element ? element.value : undefined,
819
+ }));
820
+ await action.evaluate((element) => {
821
+ window.__kerfEvaluatorActivation = 0;
822
+ element.addEventListener(
823
+ 'click',
824
+ () => {
825
+ window.__kerfEvaluatorActivation += 1;
826
+ },
827
+ { once: true },
828
+ );
829
+ });
830
+ await page.keyboard.press('Enter');
831
+ await page.waitForTimeout(0);
832
+ const after = await action.evaluate((element) => ({
833
+ activations: window.__kerfEvaluatorActivation ?? 0,
834
+ expanded: element.getAttribute('aria-expanded'),
835
+ pressed: element.getAttribute('aria-pressed'),
836
+ checked: element.getAttribute('aria-checked'),
837
+ value: 'value' in element ? element.value : undefined,
838
+ }));
839
+ const selector = await action.evaluate((element) =>
840
+ element.id ? `#${CSS.escape(element.id)}` : element.localName,
841
+ );
842
+ evidence.push({ selector, before, after });
843
+ if (
844
+ after.activations === 0 &&
845
+ before.expanded === after.expanded &&
846
+ before.pressed === after.pressed &&
847
+ before.checked === after.checked &&
848
+ before.value === after.value
849
+ )
850
+ diagnostics.push({
851
+ code: 'KUI-B023',
852
+ selector,
853
+ message: 'Representative action did not activate with Enter.',
854
+ repair:
855
+ 'Use a native control or implement Enter/Space activation and controlled state updates.',
856
+ evidence: { before, after },
857
+ });
858
+ }
859
+ return { diagnostics, evidence };
860
+ }
861
+
862
+ async function fileArtifact(path, root) {
863
+ const bytes = await readFile(path);
864
+ return {
865
+ path: relative(root, path).replaceAll('\\', '/'),
866
+ sha256: sha256(bytes),
867
+ };
868
+ }
869
+
870
+ async function clearEvaluatorScreenshots(outputDirectory) {
871
+ for (const entry of await readdir(outputDirectory, { withFileTypes: true }))
872
+ if (
873
+ entry.isFile() &&
874
+ /^(chromium|firefox|webkit)-[a-z0-9-]+\.png$/.test(entry.name)
875
+ )
876
+ await rm(resolve(outputDirectory, entry.name), { force: true });
877
+ }
878
+
879
+ export function createEvaluationReport({
880
+ target,
881
+ profileFiles,
882
+ contexts,
883
+ diagnostics,
884
+ artifacts,
885
+ recordedAt,
886
+ retention,
887
+ }) {
888
+ return {
889
+ schemaVersion: UI_EVALUATION_SCHEMA_VERSION,
890
+ tool: { name: '@kerfjs/ui/evaluator', reportVersion: 1 },
891
+ target,
892
+ recordedAt,
893
+ profileFiles,
894
+ contexts,
895
+ diagnostics,
896
+ summary: summarize(diagnostics),
897
+ artifacts: { retention, files: artifacts },
898
+ subjectiveReview: {
899
+ status: 'not-recorded',
900
+ rubric: SUBJECTIVE_REVIEW_RUBRIC,
901
+ ratings: [],
902
+ note: 'These aesthetic judgments require a named reviewer and are never inferred from objective browser checks.',
903
+ },
904
+ };
905
+ }
906
+
907
+ export async function evaluateUi({
908
+ url,
909
+ workspaceRoot = process.cwd(),
910
+ startDirectory = workspaceRoot,
911
+ packageProfile,
912
+ outputDirectory = resolve(workspaceRoot, 'kerf-ui-evidence'),
913
+ reportPath = resolve(outputDirectory, 'report.json'),
914
+ browsers = ['chromium', 'firefox', 'webkit'],
915
+ contexts: contextOverrides,
916
+ timeoutMs = 15_000,
917
+ settleMs = 100,
918
+ retention = 'on-failure',
919
+ recordedAt = new Date().toISOString(),
920
+ playwright: suppliedPlaywright,
921
+ signal,
922
+ } = {}) {
923
+ if (signal?.aborted) throw cancellationError();
924
+ if (!url) throw new Error('evaluateUi requires a running app URL.');
925
+ if (!['always', 'on-failure', 'never'].includes(retention))
926
+ throw new Error('retention must be always, on-failure, or never.');
927
+ const profileResult = await loadApplicationUiProfile({
928
+ workspaceRoot,
929
+ startDirectory,
930
+ packageProfile,
931
+ knownRules: Object.keys(UI_EVALUATION_RULES),
932
+ });
933
+ if (profileResult.diagnostics.length)
934
+ throw new Error(
935
+ `Application UI profile is invalid:\n${profileResult.diagnostics
936
+ .map(
937
+ (item) => `${item.code} ${item.source} ${item.path}: ${item.message}`,
938
+ )
939
+ .join('\n')}`,
940
+ );
941
+ const geometryContracts = await loadGeometryContracts(profileResult);
942
+ const playwright = suppliedPlaywright ?? (await import('playwright'));
943
+ const matrix = buildEvaluationContexts(contextOverrides);
944
+ const contextReports = [];
945
+ const diagnostics = [];
946
+ const screenshotPaths = [];
947
+ await mkdir(outputDirectory, { recursive: true });
948
+ await clearEvaluatorScreenshots(outputDirectory);
949
+ const evaluationTarget = relative(workspaceRoot, startDirectory).replaceAll(
950
+ '\\',
951
+ '/',
952
+ );
953
+ const exceptedRules = new Set(
954
+ (profileResult.profile.exceptions ?? [])
955
+ .filter((exception) => exception.target === evaluationTarget)
956
+ .flatMap((exception) => exception.rules),
957
+ );
958
+ const includeDiagnostic = (item) => !exceptedRules.has(item.code);
959
+ let activeBrowser;
960
+ const closeForCancellation = () => {
961
+ void activeBrowser?.close();
962
+ };
963
+ signal?.addEventListener('abort', closeForCancellation);
964
+
965
+ try {
966
+ for (const browserName of browsers) {
967
+ if (signal?.aborted) throw cancellationError();
968
+ const browserType = playwright[browserName];
969
+ if (!browserType)
970
+ throw new Error(`Unknown Playwright browser ${browserName}.`);
971
+ let browser;
972
+ try {
973
+ try {
974
+ const launch = browserType.launch();
975
+ void launch.then(
976
+ (candidate) => {
977
+ if (signal?.aborted) void candidate.close();
978
+ },
979
+ () => {},
980
+ );
981
+ browser = await abortable(launch, signal);
982
+ activeBrowser = browser;
983
+ } catch (error) {
984
+ if (signal?.aborted) throw cancellationError();
985
+ for (const matrixContext of matrix) {
986
+ const contextId = `${browserName}:${matrixContext.id}`;
987
+ const item = reportDiagnostic('KUI-B001', contextId, {
988
+ message: `Could not launch ${browserName}: ${error.message}`,
989
+ repair:
990
+ 'Install the requested Playwright browser and confirm the CI runner can launch it.',
991
+ });
992
+ if (includeDiagnostic(item)) diagnostics.push(item);
993
+ contextReports.push({
994
+ ...matrixContext,
995
+ id: contextId,
996
+ browser: browserName,
997
+ evidence: {},
998
+ summary: summarize(includeDiagnostic(item) ? [item] : []),
999
+ });
1000
+ }
1001
+ continue;
1002
+ }
1003
+ for (const matrixContext of matrix) {
1004
+ if (signal?.aborted) throw cancellationError();
1005
+ const contextId = `${browserName}:${matrixContext.id}`;
1006
+ let browserContext;
1007
+ const contextDiagnostics = [];
1008
+ let evidence = {};
1009
+ try {
1010
+ browserContext = await browser.newContext({
1011
+ viewport: {
1012
+ width: matrixContext.width,
1013
+ height: matrixContext.height,
1014
+ },
1015
+ colorScheme: matrixContext.colorScheme,
1016
+ reducedMotion: matrixContext.reducedMotion,
1017
+ });
1018
+ const page = await browserContext.newPage();
1019
+ page.setDefaultTimeout(timeoutMs);
1020
+ page.setDefaultNavigationTimeout(timeoutMs);
1021
+ await page.goto(url, {
1022
+ waitUntil: 'domcontentloaded',
1023
+ timeout: timeoutMs,
1024
+ });
1025
+ await page.emulateMedia({
1026
+ colorScheme: matrixContext.colorScheme,
1027
+ reducedMotion: matrixContext.reducedMotion,
1028
+ });
1029
+ await page.waitForTimeout(settleMs);
1030
+ const dom = await inspectPage(page, geometryContracts);
1031
+ const focus = await inspectFocus(page);
1032
+ const keyboard = await inspectKeyboardActions(page);
1033
+ for (const item of [
1034
+ ...dom.diagnostics,
1035
+ ...focus.diagnostics,
1036
+ ...keyboard.diagnostics,
1037
+ ]) {
1038
+ if (!includeDiagnostic(item)) continue;
1039
+ contextDiagnostics.push(
1040
+ reportDiagnostic(item.code, contextId, item),
1041
+ );
1042
+ }
1043
+ evidence = {
1044
+ ...dom.evidence,
1045
+ focusSequence: focus.sequence,
1046
+ keyboardActions: keyboard.evidence,
1047
+ };
1048
+ const screenshotPath = resolve(
1049
+ outputDirectory,
1050
+ `${browserName}-${matrixContext.id}.png`,
1051
+ );
1052
+ await page.screenshot({ path: screenshotPath, fullPage: true });
1053
+ screenshotPaths.push(screenshotPath);
1054
+ } catch (error) {
1055
+ if (signal?.aborted) throw cancellationError();
1056
+ const item = reportDiagnostic('KUI-B001', contextId, {
1057
+ message: error.message,
1058
+ repair:
1059
+ 'Confirm the app is running, the URL is reachable, and it settles within the configured timeout.',
1060
+ });
1061
+ if (includeDiagnostic(item)) contextDiagnostics.push(item);
1062
+ } finally {
1063
+ await browserContext?.close();
1064
+ }
1065
+ diagnostics.push(...contextDiagnostics);
1066
+ contextReports.push({
1067
+ ...matrixContext,
1068
+ id: contextId,
1069
+ browser: browserName,
1070
+ evidence,
1071
+ summary: summarize(contextDiagnostics),
1072
+ });
1073
+ }
1074
+ } finally {
1075
+ await browser?.close();
1076
+ activeBrowser = undefined;
1077
+ }
1078
+ }
1079
+ } catch (error) {
1080
+ if (signal?.aborted) {
1081
+ await clearEvaluatorScreenshots(outputDirectory);
1082
+ throw cancellationError();
1083
+ }
1084
+ throw error;
1085
+ } finally {
1086
+ signal?.removeEventListener('abort', closeForCancellation);
1087
+ }
1088
+
1089
+ const ensureActive = async () => {
1090
+ if (!signal?.aborted) return;
1091
+ await clearEvaluatorScreenshots(outputDirectory);
1092
+ throw cancellationError();
1093
+ };
1094
+ await ensureActive();
1095
+ if (
1096
+ retention === 'never' ||
1097
+ (retention === 'on-failure' &&
1098
+ !diagnostics.some((item) => item.severity === 'error'))
1099
+ ) {
1100
+ for (const path of screenshotPaths) await rm(path, { force: true });
1101
+ screenshotPaths.length = 0;
1102
+ }
1103
+ const artifacts = [];
1104
+ for (const path of screenshotPaths) {
1105
+ await ensureActive();
1106
+ artifacts.push(await fileArtifact(path, outputDirectory));
1107
+ }
1108
+ const report = createEvaluationReport({
1109
+ target: { url },
1110
+ profileFiles: profileResult.files.map((path) =>
1111
+ relative(workspaceRoot, path).replaceAll('\\', '/'),
1112
+ ),
1113
+ contexts: contextReports,
1114
+ diagnostics,
1115
+ artifacts,
1116
+ recordedAt,
1117
+ retention,
1118
+ });
1119
+ await ensureActive();
1120
+ await mkdir(dirname(reportPath), { recursive: true });
1121
+ await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
1122
+ return report;
1123
+ }
1124
+
1125
+ export function formatUiEvaluationText(report) {
1126
+ const lines = [
1127
+ `Kerf UI browser evaluation: ${report.summary.passed ? 'PASS' : 'FAIL'}`,
1128
+ `${report.contexts.length} contexts; ${report.summary.errors} errors; ${report.summary.warnings} warnings`,
1129
+ ];
1130
+ for (const diagnostic of report.diagnostics)
1131
+ lines.push(
1132
+ `${diagnostic.code} [${diagnostic.context}] ${diagnostic.selector ?? '<page>'}: ${diagnostic.message}\n Repair: ${diagnostic.repair}`,
1133
+ );
1134
+ lines.push(
1135
+ 'Human visual review: not recorded (use the report rubric with a named reviewer).',
1136
+ );
1137
+ return lines.join('\n');
1138
+ }