@veraxhq/verax 0.2.0 → 0.3.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 (217) hide show
  1. package/README.md +14 -18
  2. package/bin/verax.js +7 -0
  3. package/package.json +15 -5
  4. package/src/cli/commands/baseline.js +104 -0
  5. package/src/cli/commands/default.js +323 -111
  6. package/src/cli/commands/doctor.js +36 -4
  7. package/src/cli/commands/ga.js +243 -0
  8. package/src/cli/commands/gates.js +95 -0
  9. package/src/cli/commands/inspect.js +131 -2
  10. package/src/cli/commands/release-check.js +213 -0
  11. package/src/cli/commands/run.js +498 -103
  12. package/src/cli/commands/security-check.js +211 -0
  13. package/src/cli/commands/truth.js +114 -0
  14. package/src/cli/entry.js +305 -68
  15. package/src/cli/util/angular-component-extractor.js +179 -0
  16. package/src/cli/util/angular-navigation-detector.js +141 -0
  17. package/src/cli/util/angular-network-detector.js +161 -0
  18. package/src/cli/util/angular-state-detector.js +162 -0
  19. package/src/cli/util/ast-interactive-detector.js +546 -0
  20. package/src/cli/util/ast-network-detector.js +603 -0
  21. package/src/cli/util/ast-usestate-detector.js +602 -0
  22. package/src/cli/util/bootstrap-guard.js +86 -0
  23. package/src/cli/util/detection-engine.js +4 -3
  24. package/src/cli/util/determinism-runner.js +123 -0
  25. package/src/cli/util/determinism-writer.js +129 -0
  26. package/src/cli/util/env-url.js +4 -0
  27. package/src/cli/util/events.js +76 -0
  28. package/src/cli/util/expectation-extractor.js +380 -74
  29. package/src/cli/util/findings-writer.js +126 -15
  30. package/src/cli/util/learn-writer.js +3 -1
  31. package/src/cli/util/observation-engine.js +69 -23
  32. package/src/cli/util/observe-writer.js +3 -1
  33. package/src/cli/util/paths.js +6 -14
  34. package/src/cli/util/project-discovery.js +23 -0
  35. package/src/cli/util/project-writer.js +3 -1
  36. package/src/cli/util/redact.js +2 -2
  37. package/src/cli/util/run-resolver.js +64 -0
  38. package/src/cli/util/runtime-budget.js +147 -0
  39. package/src/cli/util/source-requirement.js +55 -0
  40. package/src/cli/util/summary-writer.js +13 -1
  41. package/src/cli/util/svelte-navigation-detector.js +163 -0
  42. package/src/cli/util/svelte-network-detector.js +80 -0
  43. package/src/cli/util/svelte-sfc-extractor.js +147 -0
  44. package/src/cli/util/svelte-state-detector.js +243 -0
  45. package/src/cli/util/vue-navigation-detector.js +177 -0
  46. package/src/cli/util/vue-sfc-extractor.js +162 -0
  47. package/src/cli/util/vue-state-detector.js +215 -0
  48. package/src/types/global.d.ts +28 -0
  49. package/src/types/ts-ast.d.ts +24 -0
  50. package/src/verax/cli/doctor.js +2 -2
  51. package/src/verax/cli/finding-explainer.js +56 -3
  52. package/src/verax/cli/init.js +1 -1
  53. package/src/verax/cli/url-safety.js +12 -2
  54. package/src/verax/cli/wizard.js +13 -2
  55. package/src/verax/core/artifacts/registry.js +154 -0
  56. package/src/verax/core/artifacts/verifier.js +980 -0
  57. package/src/verax/core/baseline/baseline.enforcer.js +137 -0
  58. package/src/verax/core/baseline/baseline.snapshot.js +231 -0
  59. package/src/verax/core/budget-engine.js +1 -1
  60. package/src/verax/core/capabilities/gates.js +499 -0
  61. package/src/verax/core/capabilities/registry.js +475 -0
  62. package/src/verax/core/confidence/confidence-compute.js +137 -0
  63. package/src/verax/core/confidence/confidence-invariants.js +234 -0
  64. package/src/verax/core/confidence/confidence-report-writer.js +112 -0
  65. package/src/verax/core/confidence/confidence-weights.js +44 -0
  66. package/src/verax/core/confidence/confidence.defaults.js +65 -0
  67. package/src/verax/core/confidence/confidence.loader.js +79 -0
  68. package/src/verax/core/confidence/confidence.schema.js +94 -0
  69. package/src/verax/core/confidence-engine-refactor.js +484 -0
  70. package/src/verax/core/confidence-engine.js +486 -0
  71. package/src/verax/core/confidence-engine.js.backup +471 -0
  72. package/src/verax/core/contracts/index.js +29 -0
  73. package/src/verax/core/contracts/types.js +185 -0
  74. package/src/verax/core/contracts/validators.js +381 -0
  75. package/src/verax/core/decision-snapshot.js +31 -4
  76. package/src/verax/core/decisions/decision.trace.js +276 -0
  77. package/src/verax/core/determinism/contract-writer.js +89 -0
  78. package/src/verax/core/determinism/contract.js +139 -0
  79. package/src/verax/core/determinism/diff.js +364 -0
  80. package/src/verax/core/determinism/engine.js +221 -0
  81. package/src/verax/core/determinism/finding-identity.js +148 -0
  82. package/src/verax/core/determinism/normalize.js +438 -0
  83. package/src/verax/core/determinism/report-writer.js +92 -0
  84. package/src/verax/core/determinism/run-fingerprint.js +118 -0
  85. package/src/verax/core/determinism-model.js +35 -6
  86. package/src/verax/core/dynamic-route-intelligence.js +528 -0
  87. package/src/verax/core/evidence/evidence-capture-service.js +307 -0
  88. package/src/verax/core/evidence/evidence-intent-ledger.js +165 -0
  89. package/src/verax/core/evidence-builder.js +487 -0
  90. package/src/verax/core/execution-mode-context.js +77 -0
  91. package/src/verax/core/execution-mode-detector.js +190 -0
  92. package/src/verax/core/failures/exit-codes.js +86 -0
  93. package/src/verax/core/failures/failure-summary.js +76 -0
  94. package/src/verax/core/failures/failure.factory.js +225 -0
  95. package/src/verax/core/failures/failure.ledger.js +132 -0
  96. package/src/verax/core/failures/failure.types.js +196 -0
  97. package/src/verax/core/failures/index.js +10 -0
  98. package/src/verax/core/ga/ga-report-writer.js +43 -0
  99. package/src/verax/core/ga/ga.artifact.js +49 -0
  100. package/src/verax/core/ga/ga.contract.js +434 -0
  101. package/src/verax/core/ga/ga.enforcer.js +86 -0
  102. package/src/verax/core/guardrails/guardrails-report-writer.js +109 -0
  103. package/src/verax/core/guardrails/policy.defaults.js +210 -0
  104. package/src/verax/core/guardrails/policy.loader.js +83 -0
  105. package/src/verax/core/guardrails/policy.schema.js +110 -0
  106. package/src/verax/core/guardrails/truth-reconciliation.js +136 -0
  107. package/src/verax/core/guardrails-engine.js +505 -0
  108. package/src/verax/core/incremental-store.js +15 -7
  109. package/src/verax/core/observe/run-timeline.js +316 -0
  110. package/src/verax/core/perf/perf.contract.js +186 -0
  111. package/src/verax/core/perf/perf.display.js +65 -0
  112. package/src/verax/core/perf/perf.enforcer.js +91 -0
  113. package/src/verax/core/perf/perf.monitor.js +209 -0
  114. package/src/verax/core/perf/perf.report.js +198 -0
  115. package/src/verax/core/pipeline-tracker.js +238 -0
  116. package/src/verax/core/product-definition.js +127 -0
  117. package/src/verax/core/release/provenance.builder.js +271 -0
  118. package/src/verax/core/release/release-report-writer.js +40 -0
  119. package/src/verax/core/release/release.enforcer.js +159 -0
  120. package/src/verax/core/release/reproducibility.check.js +221 -0
  121. package/src/verax/core/release/sbom.builder.js +283 -0
  122. package/src/verax/core/replay-validator.js +4 -4
  123. package/src/verax/core/replay.js +1 -1
  124. package/src/verax/core/report/cross-index.js +192 -0
  125. package/src/verax/core/report/human-summary.js +222 -0
  126. package/src/verax/core/route-intelligence.js +419 -0
  127. package/src/verax/core/security/secrets.scan.js +326 -0
  128. package/src/verax/core/security/security-report.js +50 -0
  129. package/src/verax/core/security/security.enforcer.js +124 -0
  130. package/src/verax/core/security/supplychain.defaults.json +38 -0
  131. package/src/verax/core/security/supplychain.policy.js +326 -0
  132. package/src/verax/core/security/vuln.scan.js +265 -0
  133. package/src/verax/core/silence-impact.js +1 -1
  134. package/src/verax/core/silence-model.js +9 -7
  135. package/src/verax/core/truth/truth.certificate.js +250 -0
  136. package/src/verax/core/ui-feedback-intelligence.js +515 -0
  137. package/src/verax/detect/comparison.js +8 -3
  138. package/src/verax/detect/confidence-engine.js +645 -57
  139. package/src/verax/detect/confidence-helper.js +33 -0
  140. package/src/verax/detect/detection-engine.js +19 -2
  141. package/src/verax/detect/dynamic-route-findings.js +335 -0
  142. package/src/verax/detect/evidence-index.js +15 -65
  143. package/src/verax/detect/expectation-chain-detector.js +417 -0
  144. package/src/verax/detect/expectation-model.js +56 -3
  145. package/src/verax/detect/explanation-helpers.js +1 -1
  146. package/src/verax/detect/finding-detector.js +2 -2
  147. package/src/verax/detect/findings-writer.js +149 -20
  148. package/src/verax/detect/flow-detector.js +4 -4
  149. package/src/verax/detect/index.js +265 -15
  150. package/src/verax/detect/interactive-findings.js +3 -4
  151. package/src/verax/detect/journey-stall-detector.js +558 -0
  152. package/src/verax/detect/route-findings.js +218 -0
  153. package/src/verax/detect/signal-mapper.js +2 -2
  154. package/src/verax/detect/skip-classifier.js +4 -4
  155. package/src/verax/detect/ui-feedback-findings.js +207 -0
  156. package/src/verax/detect/verdict-engine.js +61 -9
  157. package/src/verax/detect/view-switch-correlator.js +242 -0
  158. package/src/verax/flow/flow-engine.js +3 -2
  159. package/src/verax/flow/flow-spec.js +1 -2
  160. package/src/verax/index.js +413 -33
  161. package/src/verax/intel/effect-detector.js +1 -1
  162. package/src/verax/intel/index.js +2 -2
  163. package/src/verax/intel/route-extractor.js +3 -3
  164. package/src/verax/intel/vue-navigation-extractor.js +81 -18
  165. package/src/verax/intel/vue-router-extractor.js +4 -2
  166. package/src/verax/learn/action-contract-extractor.js +684 -66
  167. package/src/verax/learn/ast-contract-extractor.js +53 -1
  168. package/src/verax/learn/index.js +36 -2
  169. package/src/verax/learn/manifest-writer.js +28 -14
  170. package/src/verax/learn/route-extractor.js +1 -1
  171. package/src/verax/learn/route-validator.js +12 -8
  172. package/src/verax/learn/state-extractor.js +1 -1
  173. package/src/verax/learn/static-extractor-navigation.js +1 -1
  174. package/src/verax/learn/static-extractor-validation.js +2 -2
  175. package/src/verax/learn/static-extractor.js +8 -7
  176. package/src/verax/learn/ts-contract-resolver.js +14 -12
  177. package/src/verax/observe/browser.js +22 -3
  178. package/src/verax/observe/console-sensor.js +2 -2
  179. package/src/verax/observe/expectation-executor.js +2 -1
  180. package/src/verax/observe/focus-sensor.js +1 -1
  181. package/src/verax/observe/human-driver.js +29 -10
  182. package/src/verax/observe/index.js +92 -844
  183. package/src/verax/observe/interaction-discovery.js +27 -15
  184. package/src/verax/observe/interaction-runner.js +31 -14
  185. package/src/verax/observe/loading-sensor.js +6 -0
  186. package/src/verax/observe/navigation-sensor.js +1 -1
  187. package/src/verax/observe/observe-context.js +205 -0
  188. package/src/verax/observe/observe-helpers.js +191 -0
  189. package/src/verax/observe/observe-runner.js +226 -0
  190. package/src/verax/observe/observers/budget-observer.js +185 -0
  191. package/src/verax/observe/observers/console-observer.js +102 -0
  192. package/src/verax/observe/observers/coverage-observer.js +107 -0
  193. package/src/verax/observe/observers/interaction-observer.js +471 -0
  194. package/src/verax/observe/observers/navigation-observer.js +132 -0
  195. package/src/verax/observe/observers/network-observer.js +87 -0
  196. package/src/verax/observe/observers/safety-observer.js +82 -0
  197. package/src/verax/observe/observers/ui-feedback-observer.js +99 -0
  198. package/src/verax/observe/settle.js +1 -0
  199. package/src/verax/observe/state-sensor.js +8 -4
  200. package/src/verax/observe/state-ui-sensor.js +7 -1
  201. package/src/verax/observe/traces-writer.js +27 -16
  202. package/src/verax/observe/ui-feedback-detector.js +742 -0
  203. package/src/verax/observe/ui-signal-sensor.js +155 -2
  204. package/src/verax/scan-summary-writer.js +46 -9
  205. package/src/verax/shared/artifact-manager.js +9 -6
  206. package/src/verax/shared/budget-profiles.js +2 -2
  207. package/src/verax/shared/caching.js +1 -1
  208. package/src/verax/shared/config-loader.js +1 -2
  209. package/src/verax/shared/css-spinner-rules.js +204 -0
  210. package/src/verax/shared/dynamic-route-utils.js +12 -6
  211. package/src/verax/shared/retry-policy.js +1 -6
  212. package/src/verax/shared/root-artifacts.js +1 -1
  213. package/src/verax/shared/view-switch-rules.js +208 -0
  214. package/src/verax/shared/zip-artifacts.js +1 -0
  215. package/src/verax/validate/context-validator.js +1 -1
  216. package/src/verax/observe/index.js.backup +0 -1
  217. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Gap 5.1: Runtime UI Feedback Detection
3
+ *
4
+ * Detects strong, evidence-backed UI feedback signals after interactions.
5
+ * Conservative approach: prefers false negatives over false positives.
6
+ *
7
+ * Signals Detected:
8
+ * 1. DOM Change Significance - meaningful changes in viewport/target container
9
+ * 2. Loading Indicators - spinners, skeletons, progressbars with aria/CSS evidence
10
+ * 3. Button State Transitions - disabled/enabled changes, label changes
11
+ * 4. Notifications - toasts, alerts, aria-live updates
12
+ * 5. Navigation - URL changes, history state transitions
13
+ * 6. Focus/Scroll Changes - meaningful focus movements, significant scrolls
14
+ */
15
+
16
+ /**
17
+ * UI Feedback Detector
18
+ * Captures before/after state and computes feedback signals
19
+ */
20
+ export class UIFeedbackDetector {
21
+ constructor() {
22
+ this.beforeState = null;
23
+ this.afterState = null;
24
+ this.interactionTarget = null;
25
+ }
26
+
27
+ /**
28
+ * Capture UI state before interaction
29
+ * @param {import('playwright').Page} page - Playwright page
30
+ * @param {Object} options - { targetSelector?: string }
31
+ */
32
+ async captureBefore(page, options = {}) {
33
+ this.beforeState = await this._captureState(page, options.targetSelector);
34
+ this.interactionTarget = options.targetSelector || null;
35
+ }
36
+
37
+ /**
38
+ * Capture UI state after interaction
39
+ * @param {import('playwright').Page} page - Playwright page
40
+ */
41
+ async captureAfter(page) {
42
+ this.afterState = await this._captureState(page, this.interactionTarget);
43
+ }
44
+
45
+ /**
46
+ * Compute UI feedback signals from before/after states
47
+ * @returns {Object} Feedback signals with evidence
48
+ */
49
+ computeFeedbackSignals() {
50
+ if (!this.beforeState || !this.afterState) {
51
+ return this._emptySignals();
52
+ }
53
+
54
+ const signals = {
55
+ domChange: this._computeDomChangeSignal(),
56
+ loading: this._computeLoadingSignal(),
57
+ buttonStateTransition: this._computeButtonStateSignal(),
58
+ notification: this._computeNotificationSignal(),
59
+ navigation: this._computeNavigationSignal(),
60
+ focusChange: this._computeFocusChangeSignal(),
61
+ scrollChange: this._computeScrollChangeSignal()
62
+ };
63
+
64
+ // Compute overall feedback score (0..1)
65
+ const overallScore = this._computeOverallScore(signals);
66
+
67
+ return {
68
+ interactionId: this.beforeState.timestamp,
69
+ signals,
70
+ overallUiFeedbackScore: overallScore,
71
+ _metadata: {
72
+ capturedAt: new Date().toISOString(),
73
+ interactionTarget: this.interactionTarget
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Capture state snapshot from page
80
+ * @private
81
+ */
82
+ async _captureState(page, targetSelector = null) {
83
+ try {
84
+ const state = await page.evaluate((selector) => {
85
+ const result = {
86
+ timestamp: Date.now(),
87
+ url: window.location.href,
88
+
89
+ // DOM Structure
90
+ viewport: {
91
+ elementCount: 0,
92
+ textContent: '',
93
+ visibleText: ''
94
+ },
95
+ targetContainer: selector ? {
96
+ elementCount: 0,
97
+ textContent: '',
98
+ innerHTML: ''
99
+ } : null,
100
+
101
+ // Loading Indicators
102
+ loading: {
103
+ ariaBusy: [],
104
+ progressBars: [],
105
+ spinners: [],
106
+ skeletons: []
107
+ },
108
+
109
+ // Button States
110
+ buttons: [],
111
+
112
+ // Notifications/Alerts
113
+ notifications: {
114
+ alerts: [],
115
+ liveRegions: [],
116
+ toasts: []
117
+ },
118
+
119
+ // Navigation
120
+ navigation: {
121
+ pathname: window.location.pathname,
122
+ search: window.location.search,
123
+ hash: window.location.hash
124
+ },
125
+
126
+ // Focus
127
+ focus: {
128
+ activeElement: null,
129
+ hasFocus: document.hasFocus()
130
+ },
131
+
132
+ // Scroll
133
+ scroll: {
134
+ x: window.scrollX || window.pageXOffset || 0,
135
+ y: window.scrollY || window.pageYOffset || 0
136
+ }
137
+ };
138
+
139
+ // Capture viewport content (above-the-fold)
140
+ const viewportHeight = window.innerHeight;
141
+ const viewportElements = Array.from(document.body?.querySelectorAll('*') || []).filter(el => {
142
+ const rect = el.getBoundingClientRect();
143
+ return rect.top < viewportHeight && rect.bottom > 0;
144
+ });
145
+ result.viewport.elementCount = viewportElements.length;
146
+ result.viewport.visibleText = viewportElements
147
+ .map(el => (el.textContent || '').trim())
148
+ .filter(t => t.length > 0 && t.length < 200)
149
+ .slice(0, 10)
150
+ .join(' | ');
151
+
152
+ // Capture target container if selector provided
153
+ if (selector) {
154
+ try {
155
+ const target = document.querySelector(selector);
156
+ if (target) {
157
+ const containerElements = target.querySelectorAll('*');
158
+ result.targetContainer.elementCount = containerElements.length;
159
+ result.targetContainer.textContent = (target.textContent || '').trim().slice(0, 500);
160
+ result.targetContainer.innerHTML = target.innerHTML.slice(0, 1000);
161
+ }
162
+ } catch (e) {
163
+ // Selector not found - leave null
164
+ }
165
+ }
166
+
167
+ // Detect loading indicators
168
+ // 1. aria-busy
169
+ const ariaBusyElements = Array.from(document.querySelectorAll('[aria-busy="true"]'));
170
+ ariaBusyElements.forEach(el => {
171
+ const style = window.getComputedStyle(el);
172
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
173
+ result.loading.ariaBusy.push({
174
+ tag: el.tagName.toLowerCase(),
175
+ text: (el.textContent || '').trim().slice(0, 50),
176
+ role: el.getAttribute('role')
177
+ });
178
+ }
179
+ });
180
+
181
+ // 2. role="progressbar"
182
+ const progressBars = Array.from(document.querySelectorAll('[role="progressbar"]'));
183
+ progressBars.forEach(el => {
184
+ const style = window.getComputedStyle(el);
185
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
186
+ result.loading.progressBars.push({
187
+ valueNow: el.getAttribute('aria-valuenow'),
188
+ valueMin: el.getAttribute('aria-valuemin'),
189
+ valueMax: el.getAttribute('aria-valuemax'),
190
+ label: el.getAttribute('aria-label')
191
+ });
192
+ }
193
+ });
194
+
195
+ // 3. Common spinner/loading classes (conservative: require animation)
196
+ const spinnerCandidates = Array.from(document.querySelectorAll(
197
+ '[class*="spinner"], [class*="loading"], [class*="loader"], [data-loading]'
198
+ ));
199
+ spinnerCandidates.forEach(el => {
200
+ const style = window.getComputedStyle(el);
201
+ const hasAnimation = style.animationName !== 'none' || style.animationDuration !== '0s';
202
+ if (style.display !== 'none' && style.visibility !== 'hidden' && hasAnimation) {
203
+ result.loading.spinners.push({
204
+ className: el.className.slice(0, 100),
205
+ tag: el.tagName.toLowerCase()
206
+ });
207
+ }
208
+ });
209
+
210
+ // 4. Skeleton loaders (conservative: require specific patterns)
211
+ const skeletonCandidates = Array.from(document.querySelectorAll(
212
+ '[class*="skeleton"], [aria-label*="skeleton"], [aria-label*="placeholder"]'
213
+ ));
214
+ skeletonCandidates.forEach(el => {
215
+ const style = window.getComputedStyle(el);
216
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
217
+ result.loading.skeletons.push({
218
+ className: el.className.slice(0, 100),
219
+ ariaLabel: el.getAttribute('aria-label')
220
+ });
221
+ }
222
+ });
223
+
224
+ // Capture button states (all buttons + actionable elements)
225
+ const actionableElements = Array.from(document.querySelectorAll(
226
+ 'button, [role="button"], input[type="submit"], input[type="button"], a[role="button"]'
227
+ ));
228
+ actionableElements.forEach(el => {
229
+ const style = window.getComputedStyle(el);
230
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
231
+ result.buttons.push({
232
+ selector: el.id ? `#${el.id}` : (el.className ? `.${el.className.split(' ')[0]}` : el.tagName.toLowerCase()),
233
+ text: (el.textContent || '').trim().slice(0, 100),
234
+ disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
235
+ ariaBusy: el.getAttribute('aria-busy'),
236
+ tag: el.tagName.toLowerCase(),
237
+ type: el.getAttribute('type')
238
+ });
239
+ }
240
+ });
241
+
242
+ // Capture notifications/alerts
243
+ // 1. role="alert"
244
+ const alerts = Array.from(document.querySelectorAll('[role="alert"]'));
245
+ alerts.forEach(el => {
246
+ const style = window.getComputedStyle(el);
247
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
248
+ result.notifications.alerts.push({
249
+ text: (el.textContent || '').trim().slice(0, 200),
250
+ className: el.className.slice(0, 100)
251
+ });
252
+ }
253
+ });
254
+
255
+ // 2. aria-live regions
256
+ const liveRegions = Array.from(document.querySelectorAll('[aria-live]'));
257
+ liveRegions.forEach(el => {
258
+ const style = window.getComputedStyle(el);
259
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
260
+ result.notifications.liveRegions.push({
261
+ text: (el.textContent || '').trim().slice(0, 200),
262
+ liveValue: el.getAttribute('aria-live'),
263
+ role: el.getAttribute('role')
264
+ });
265
+ }
266
+ });
267
+
268
+ // 3. Toast/snackbar patterns (conservative: require visibility + specific classes)
269
+ const toastCandidates = Array.from(document.querySelectorAll(
270
+ '[class*="toast"], [class*="snackbar"], [class*="notification"], [class*="alert"]'
271
+ ));
272
+ toastCandidates.forEach(el => {
273
+ const style = window.getComputedStyle(el);
274
+ const rect = el.getBoundingClientRect();
275
+ // Toast should be visible and positioned (not in normal flow)
276
+ const isPositioned = style.position === 'fixed' || style.position === 'absolute';
277
+ if (style.display !== 'none' && style.visibility !== 'hidden' && isPositioned && rect.width > 0) {
278
+ result.notifications.toasts.push({
279
+ text: (el.textContent || '').trim().slice(0, 200),
280
+ className: el.className.slice(0, 100),
281
+ position: style.position
282
+ });
283
+ }
284
+ });
285
+
286
+ // Capture active element
287
+ if (document.activeElement && document.activeElement !== document.body) {
288
+ const activeEl = document.activeElement;
289
+ result.focus.activeElement = {
290
+ tag: activeEl.tagName.toLowerCase(),
291
+ id: activeEl.id || null,
292
+ className: activeEl.className ? activeEl.className.slice(0, 100) : null,
293
+ type: activeEl.getAttribute('type'),
294
+ name: activeEl.getAttribute('name'),
295
+ role: activeEl.getAttribute('role'),
296
+ ariaLabel: activeEl.getAttribute('aria-label')
297
+ };
298
+ }
299
+
300
+ return result;
301
+ }, targetSelector);
302
+
303
+ return state;
304
+ } catch (error) {
305
+ // Return minimal state on error
306
+ return {
307
+ timestamp: Date.now(),
308
+ url: '',
309
+ viewport: { elementCount: 0, textContent: '', visibleText: '' },
310
+ targetContainer: null,
311
+ loading: { ariaBusy: [], progressBars: [], spinners: [], skeletons: [] },
312
+ buttons: [],
313
+ notifications: { alerts: [], liveRegions: [], toasts: [] },
314
+ navigation: { pathname: '', search: '', hash: '' },
315
+ focus: { activeElement: null, hasFocus: false },
316
+ scroll: { x: 0, y: 0 },
317
+ _error: error.message
318
+ };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Compute DOM change signal
324
+ * @private
325
+ */
326
+ _computeDomChangeSignal() {
327
+ const before = this.beforeState;
328
+ const after = this.afterState;
329
+
330
+ // Check viewport changes
331
+ const viewportElementDelta = Math.abs(after.viewport.elementCount - before.viewport.elementCount);
332
+ const viewportTextChanged = before.viewport.visibleText !== after.viewport.visibleText;
333
+
334
+ // Check target container changes (if available)
335
+ let targetChanged = false;
336
+ let targetScore = 0;
337
+ const targetEvidence = [];
338
+
339
+ if (before.targetContainer && after.targetContainer) {
340
+ const targetElementDelta = Math.abs(after.targetContainer.elementCount - before.targetContainer.elementCount);
341
+ const targetTextChanged = before.targetContainer.textContent !== after.targetContainer.textContent;
342
+
343
+ targetChanged = targetElementDelta > 0 || targetTextChanged;
344
+
345
+ if (targetElementDelta > 5) {
346
+ targetScore += 0.4;
347
+ targetEvidence.push(`${targetElementDelta} elements added/removed in target`);
348
+ } else if (targetElementDelta > 0) {
349
+ targetScore += 0.2;
350
+ targetEvidence.push(`${targetElementDelta} element(s) changed in target`);
351
+ }
352
+
353
+ if (targetTextChanged) {
354
+ targetScore += 0.3;
355
+ targetEvidence.push('Text content changed in target');
356
+ }
357
+ }
358
+
359
+ // Compute viewport score
360
+ let viewportScore = 0;
361
+ const viewportEvidence = [];
362
+
363
+ if (viewportElementDelta > 10) {
364
+ viewportScore += 0.3;
365
+ viewportEvidence.push(`${viewportElementDelta} elements added/removed in viewport`);
366
+ } else if (viewportElementDelta > 3) {
367
+ viewportScore += 0.15;
368
+ viewportEvidence.push(`${viewportElementDelta} element(s) changed in viewport`);
369
+ }
370
+
371
+ if (viewportTextChanged) {
372
+ viewportScore += 0.2;
373
+ viewportEvidence.push('Visible text changed in viewport');
374
+ }
375
+
376
+ // Overall DOM change score (prioritize target over viewport)
377
+ const score = targetChanged ? Math.min(targetScore + viewportScore * 0.3, 1.0) : viewportScore;
378
+ const happened = score > 0.1; // Conservative threshold
379
+
380
+ return {
381
+ happened,
382
+ score,
383
+ evidence: {
384
+ viewport: {
385
+ elementDelta: viewportElementDelta,
386
+ textChanged: viewportTextChanged,
387
+ changes: viewportEvidence
388
+ },
389
+ target: targetChanged ? {
390
+ elementDelta: after.targetContainer.elementCount - before.targetContainer.elementCount,
391
+ textChanged: before.targetContainer.textContent !== after.targetContainer.textContent,
392
+ changes: targetEvidence
393
+ } : null
394
+ }
395
+ };
396
+ }
397
+
398
+ /**
399
+ * Compute loading indicator signal
400
+ * @private
401
+ */
402
+ _computeLoadingSignal() {
403
+ const before = this.beforeState.loading;
404
+ const after = this.afterState.loading;
405
+
406
+ const appeared = {
407
+ ariaBusy: after.ariaBusy.length > before.ariaBusy.length,
408
+ progressBars: after.progressBars.length > before.progressBars.length,
409
+ spinners: after.spinners.length > before.spinners.length,
410
+ skeletons: after.skeletons.length > before.skeletons.length
411
+ };
412
+
413
+ const disappeared = {
414
+ ariaBusy: before.ariaBusy.length > after.ariaBusy.length,
415
+ progressBars: before.progressBars.length > after.progressBars.length,
416
+ spinners: before.spinners.length > after.spinners.length,
417
+ skeletons: before.skeletons.length > after.skeletons.length
418
+ };
419
+
420
+ const evidence = [];
421
+
422
+ if (appeared.ariaBusy) {
423
+ evidence.push(`${after.ariaBusy.length - before.ariaBusy.length} aria-busy elements appeared`);
424
+ }
425
+ if (appeared.progressBars) {
426
+ evidence.push(`${after.progressBars.length - before.progressBars.length} progress bars appeared`);
427
+ }
428
+ if (appeared.spinners) {
429
+ evidence.push(`${after.spinners.length - before.spinners.length} spinners appeared`);
430
+ }
431
+ if (appeared.skeletons) {
432
+ evidence.push(`${after.skeletons.length - before.skeletons.length} skeleton loaders appeared`);
433
+ }
434
+
435
+ if (disappeared.ariaBusy) {
436
+ evidence.push(`${before.ariaBusy.length - after.ariaBusy.length} aria-busy elements disappeared`);
437
+ }
438
+ if (disappeared.progressBars) {
439
+ evidence.push(`${before.progressBars.length - after.progressBars.length} progress bars disappeared`);
440
+ }
441
+ if (disappeared.spinners) {
442
+ evidence.push(`${before.spinners.length - after.spinners.length} spinners disappeared`);
443
+ }
444
+ if (disappeared.skeletons) {
445
+ evidence.push(`${before.skeletons.length - after.skeletons.length} skeleton loaders disappeared`);
446
+ }
447
+
448
+ const hasAppeared = appeared.ariaBusy || appeared.progressBars || appeared.spinners || appeared.skeletons;
449
+ const hasDisappeared = disappeared.ariaBusy || disappeared.progressBars || disappeared.spinners || disappeared.skeletons;
450
+
451
+ return {
452
+ appeared: hasAppeared,
453
+ disappeared: hasDisappeared,
454
+ evidence: {
455
+ appeared,
456
+ disappeared,
457
+ details: evidence,
458
+ beforeCount: before.ariaBusy.length + before.progressBars.length + before.spinners.length + before.skeletons.length,
459
+ afterCount: after.ariaBusy.length + after.progressBars.length + after.spinners.length + after.skeletons.length
460
+ }
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Compute button state transition signal
466
+ * @private
467
+ */
468
+ _computeButtonStateSignal() {
469
+ const before = this.beforeState.buttons;
470
+ const after = this.afterState.buttons;
471
+
472
+ const transitions = [];
473
+
474
+ // Match buttons by selector+text (fuzzy matching)
475
+ // First pass: exact selector match (for text changes)
476
+ before.forEach(beforeBtn => {
477
+ const afterBtn = after.find(a => a.selector === beforeBtn.selector);
478
+
479
+ if (afterBtn) {
480
+ // Check for state transitions
481
+ if (beforeBtn.disabled !== afterBtn.disabled) {
482
+ transitions.push({
483
+ selector: beforeBtn.selector,
484
+ type: 'disabled-toggle',
485
+ before: beforeBtn.disabled,
486
+ after: afterBtn.disabled,
487
+ text: beforeBtn.text
488
+ });
489
+ }
490
+
491
+ // Check for text changes (e.g., "Submit" -> "Saving...")
492
+ if (beforeBtn.text !== afterBtn.text && beforeBtn.text.length > 0 && afterBtn.text.length > 0) {
493
+ transitions.push({
494
+ selector: beforeBtn.selector,
495
+ type: 'text-change',
496
+ before: beforeBtn.text,
497
+ after: afterBtn.text
498
+ });
499
+ }
500
+
501
+ // Check for aria-busy changes
502
+ if (beforeBtn.ariaBusy !== afterBtn.ariaBusy) {
503
+ transitions.push({
504
+ selector: beforeBtn.selector,
505
+ type: 'aria-busy-change',
506
+ before: beforeBtn.ariaBusy,
507
+ after: afterBtn.ariaBusy,
508
+ text: beforeBtn.text
509
+ });
510
+ }
511
+ }
512
+ });
513
+
514
+ return {
515
+ happened: transitions.length > 0,
516
+ evidence: {
517
+ transitionCount: transitions.length,
518
+ transitions: transitions.slice(0, 5) // Limit to first 5 transitions
519
+ }
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Compute notification signal
525
+ * @private
526
+ */
527
+ _computeNotificationSignal() {
528
+ const before = this.beforeState.notifications;
529
+ const after = this.afterState.notifications;
530
+
531
+ const newAlerts = after.alerts.filter(a =>
532
+ !before.alerts.some(b => b.text === a.text)
533
+ );
534
+
535
+ const newLiveRegions = after.liveRegions.filter(a =>
536
+ !before.liveRegions.some(b => b.text === a.text)
537
+ );
538
+
539
+ const newToasts = after.toasts.filter(a =>
540
+ !before.toasts.some(b => b.text === a.text)
541
+ );
542
+
543
+ const happened = newAlerts.length > 0 || newLiveRegions.length > 0 || newToasts.length > 0;
544
+
545
+ return {
546
+ happened,
547
+ evidence: {
548
+ newAlerts: newAlerts.slice(0, 3),
549
+ newLiveRegions: newLiveRegions.slice(0, 3),
550
+ newToasts: newToasts.slice(0, 3),
551
+ totalNew: newAlerts.length + newLiveRegions.length + newToasts.length
552
+ }
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Compute navigation signal
558
+ * @private
559
+ */
560
+ _computeNavigationSignal() {
561
+ const before = this.beforeState.navigation;
562
+ const after = this.afterState.navigation;
563
+
564
+ const pathnameChanged = before.pathname !== after.pathname;
565
+ const searchChanged = before.search !== after.search;
566
+ const hashChanged = before.hash !== after.hash;
567
+
568
+ const happened = pathnameChanged || searchChanged || hashChanged;
569
+
570
+ return {
571
+ happened,
572
+ from: `${before.pathname}${before.search}${before.hash}`,
573
+ to: `${after.pathname}${after.search}${after.hash}`,
574
+ evidence: {
575
+ pathnameChanged,
576
+ searchChanged,
577
+ hashChanged,
578
+ urlChanged: this.beforeState.url !== this.afterState.url
579
+ }
580
+ };
581
+ }
582
+
583
+ /**
584
+ * Compute focus change signal
585
+ * @private
586
+ */
587
+ _computeFocusChangeSignal() {
588
+ const before = this.beforeState.focus;
589
+ const after = this.afterState.focus;
590
+
591
+ // Check if active element changed
592
+ const beforeId = before.activeElement ?
593
+ `${before.activeElement.tag}#${before.activeElement.id || before.activeElement.name || ''}` : null;
594
+ const afterId = after.activeElement ?
595
+ `${after.activeElement.tag}#${after.activeElement.id || after.activeElement.name || ''}` : null;
596
+
597
+ const happened = beforeId !== afterId;
598
+
599
+ // Conservative: only flag as meaningful if focus moved to form field or error element
600
+ const isMeaningful = happened && after.activeElement && (
601
+ after.activeElement.tag === 'input' ||
602
+ after.activeElement.tag === 'textarea' ||
603
+ after.activeElement.tag === 'select' ||
604
+ (after.activeElement.ariaLabel && after.activeElement.ariaLabel.toLowerCase().includes('error')) || false
605
+ );
606
+
607
+ return {
608
+ happened: isMeaningful,
609
+ from: before.activeElement ? {
610
+ tag: before.activeElement.tag,
611
+ id: before.activeElement.id,
612
+ name: before.activeElement.name,
613
+ role: before.activeElement.role
614
+ } : null,
615
+ to: after.activeElement ? {
616
+ tag: after.activeElement.tag,
617
+ id: after.activeElement.id,
618
+ name: after.activeElement.name,
619
+ role: after.activeElement.role
620
+ } : null,
621
+ evidence: {
622
+ focusMovedToFormField: isMeaningful,
623
+ beforeHasFocus: before.hasFocus,
624
+ afterHasFocus: after.hasFocus
625
+ }
626
+ };
627
+ }
628
+
629
+ /**
630
+ * Compute scroll change signal
631
+ * @private
632
+ */
633
+ _computeScrollChangeSignal() {
634
+ const before = this.beforeState.scroll;
635
+ const after = this.afterState.scroll;
636
+
637
+ const deltaX = Math.abs(after.x - before.x);
638
+ const deltaY = Math.abs(after.y - before.y);
639
+
640
+ // Conservative threshold: 100px vertical scroll or 50px horizontal
641
+ const isSignificant = deltaY > 100 || deltaX > 50;
642
+
643
+ return {
644
+ happened: isSignificant,
645
+ delta: {
646
+ x: after.x - before.x,
647
+ y: after.y - before.y
648
+ },
649
+ evidence: {
650
+ beforePosition: { x: before.x, y: before.y },
651
+ afterPosition: { x: after.x, y: after.y },
652
+ scrollDistance: Math.sqrt(deltaX * deltaX + deltaY * deltaY)
653
+ }
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Compute overall UI feedback score (0..1)
659
+ * @private
660
+ */
661
+ _computeOverallScore(signals) {
662
+ let score = 0;
663
+ let weights = 0;
664
+
665
+ // DOM change: weight 0.25
666
+ if (signals.domChange.happened) {
667
+ score += signals.domChange.score * 0.25;
668
+ weights += 0.25;
669
+ }
670
+
671
+ // Loading indicators: weight 0.2
672
+ if (signals.loading.appeared || signals.loading.disappeared) {
673
+ score += 0.2;
674
+ weights += 0.2;
675
+ }
676
+
677
+ // Button state: weight 0.2
678
+ if (signals.buttonStateTransition.happened) {
679
+ score += 0.2;
680
+ weights += 0.2;
681
+ }
682
+
683
+ // Notifications: weight 0.15
684
+ if (signals.notification.happened) {
685
+ score += 0.15;
686
+ weights += 0.15;
687
+ }
688
+
689
+ // Navigation: weight 0.15
690
+ if (signals.navigation.happened) {
691
+ score += 0.15;
692
+ weights += 0.15;
693
+ }
694
+
695
+ // Focus change: weight 0.05 (lower weight, auxiliary signal)
696
+ if (signals.focusChange.happened) {
697
+ score += 0.05;
698
+ weights += 0.05;
699
+ }
700
+
701
+ // Scroll change: weight 0.05 (lower weight, auxiliary signal)
702
+ if (signals.scrollChange.happened) {
703
+ score += 0.05;
704
+ weights += 0.05;
705
+ }
706
+
707
+ // Normalize by weights (if no signals, score = 0)
708
+ return weights > 0 ? score : 0;
709
+ }
710
+
711
+ /**
712
+ * Return empty signals structure
713
+ * @private
714
+ */
715
+ _emptySignals() {
716
+ return {
717
+ interactionId: null,
718
+ signals: {
719
+ domChange: { happened: false, score: 0, evidence: {} },
720
+ loading: { appeared: false, disappeared: false, evidence: {} },
721
+ buttonStateTransition: { happened: false, evidence: {} },
722
+ notification: { happened: false, evidence: {} },
723
+ navigation: { happened: false, from: '', to: '', evidence: {} },
724
+ focusChange: { happened: false, from: null, to: null, evidence: {} },
725
+ scrollChange: { happened: false, delta: { x: 0, y: 0 }, evidence: {} }
726
+ },
727
+ overallUiFeedbackScore: 0,
728
+ _metadata: {
729
+ error: 'No before/after state captured'
730
+ }
731
+ };
732
+ }
733
+
734
+ /**
735
+ * Reset detector state
736
+ */
737
+ reset() {
738
+ this.beforeState = null;
739
+ this.afterState = null;
740
+ this.interactionTarget = null;
741
+ }
742
+ }