explorbot 0.5.7 → 0.5.9

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 (77) hide show
  1. package/bin/explorbot-cli.ts +25 -3
  2. package/boat/prima/README.md +3 -0
  3. package/boat/prima/src/cli.ts +7 -1
  4. package/boat/prima/src/envelope.ts +18 -4
  5. package/boat/prima/src/help.ts +15 -4
  6. package/boat/prima/src/prima.ts +181 -25
  7. package/dist/bin/explorbot-cli.js +31 -3
  8. package/dist/boat/prima/src/cli.js +6 -1
  9. package/dist/boat/prima/src/envelope.js +16 -3
  10. package/dist/boat/prima/src/help.js +14 -4
  11. package/dist/boat/prima/src/prima.js +191 -27
  12. package/dist/package.json +1 -1
  13. package/dist/rules/navigator/verification-actions.md +5 -3
  14. package/dist/rules/planner/ignored-pages.md +6 -0
  15. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  16. package/dist/rules/quartermaster/ux/charts.md +6 -0
  17. package/dist/rules/quartermaster/ux/copy.md +9 -0
  18. package/dist/rules/quartermaster/ux/forms.md +7 -0
  19. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  20. package/dist/rules/quartermaster/ux/motion.md +5 -0
  21. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  22. package/dist/rules/quartermaster/ux/readability.md +6 -0
  23. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  24. package/dist/src/ai/judge.d.ts +3 -1
  25. package/dist/src/ai/judge.js +13 -3
  26. package/dist/src/ai/navigator.js +3 -2
  27. package/dist/src/ai/pilot.js +10 -4
  28. package/dist/src/ai/planner.js +9 -1
  29. package/dist/src/ai/provider.d.ts +4 -0
  30. package/dist/src/ai/provider.js +27 -5
  31. package/dist/src/ai/quartermaster.d.ts +31 -3
  32. package/dist/src/ai/quartermaster.js +262 -17
  33. package/dist/src/ai/tester.js +6 -3
  34. package/dist/src/commands/explore-command.js +7 -0
  35. package/dist/src/commands/init-command.d.ts +1 -0
  36. package/dist/src/commands/init-command.js +87 -36
  37. package/dist/src/components/InitWizard.d.ts +5 -1
  38. package/dist/src/components/InitWizard.js +29 -11
  39. package/dist/src/config.d.ts +2 -0
  40. package/dist/src/config.js +29 -7
  41. package/dist/src/explorbot.d.ts +1 -1
  42. package/dist/src/explorbot.js +8 -13
  43. package/dist/src/explorer.js +1 -0
  44. package/dist/src/global-config.js +6 -2
  45. package/dist/src/utils/ux-probe.d.ts +25 -0
  46. package/dist/src/utils/ux-probe.js +155 -0
  47. package/docs/basics/getting-started.md +23 -10
  48. package/docs/basics/providers.md +2 -0
  49. package/docs/reference/commands.md +5 -2
  50. package/docs/reference/configuration.md +1 -0
  51. package/package.json +1 -1
  52. package/rules/navigator/verification-actions.md +5 -3
  53. package/rules/planner/ignored-pages.md +6 -0
  54. package/rules/quartermaster/ux/accessibility.md +8 -0
  55. package/rules/quartermaster/ux/charts.md +6 -0
  56. package/rules/quartermaster/ux/copy.md +9 -0
  57. package/rules/quartermaster/ux/forms.md +7 -0
  58. package/rules/quartermaster/ux/interaction.md +7 -0
  59. package/rules/quartermaster/ux/motion.md +5 -0
  60. package/rules/quartermaster/ux/navigation.md +7 -0
  61. package/rules/quartermaster/ux/readability.md +6 -0
  62. package/rules/quartermaster/ux/responsive.md +7 -0
  63. package/src/ai/judge.ts +11 -3
  64. package/src/ai/navigator.ts +2 -2
  65. package/src/ai/pilot.ts +10 -4
  66. package/src/ai/planner.ts +10 -1
  67. package/src/ai/provider.ts +29 -6
  68. package/src/ai/quartermaster.ts +300 -18
  69. package/src/ai/tester.ts +5 -3
  70. package/src/commands/explore-command.ts +8 -0
  71. package/src/commands/init-command.ts +105 -38
  72. package/src/components/InitWizard.tsx +43 -11
  73. package/src/config.ts +32 -7
  74. package/src/explorbot.ts +9 -13
  75. package/src/explorer.ts +1 -0
  76. package/src/global-config.ts +5 -2
  77. package/src/utils/ux-probe.ts +163 -0
@@ -15,14 +15,16 @@ interface InitWizardProps {
15
15
  onLocal: () => void;
16
16
  onComplete: () => void;
17
17
  onCancel: () => void;
18
- onLocalProvider?: (provider: string) => void;
18
+ onLocalSetup?: (setup: { provider: string; apiKey: string; url: string }) => void;
19
19
  }
20
20
 
21
- const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
22
- const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
21
+ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
22
+ const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate' | 'url'>(mode === 'choose' ? 'target' : 'provider');
23
23
  const [targetIndex, setTargetIndex] = useState(0);
24
24
  const [providerIndex, setProviderIndex] = useState(0);
25
25
  const [apiKey, setApiKey] = useState('');
26
+ const [url, setUrl] = useState('');
27
+ const [urlError, setUrlError] = useState('');
26
28
  const [status, setStatus] = useState('');
27
29
  const [error, setError] = useState('');
28
30
 
@@ -30,6 +32,11 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
30
32
  const envKey = PROVIDERS[provider].envKey;
31
33
 
32
34
  const save = () => {
35
+ if (mode === 'local') {
36
+ setStatus('');
37
+ setStep('url');
38
+ return;
39
+ }
33
40
  writeGlobalConfig(provider, apiKey.trim());
34
41
  onComplete();
35
42
  };
@@ -61,7 +68,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
61
68
  return;
62
69
  }
63
70
 
64
- if (step === 'key') return;
71
+ if (step === 'key' || step === 'url') return;
65
72
 
66
73
  if (key.escape) {
67
74
  onCancel();
@@ -79,10 +86,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
79
86
  if (step === 'provider') {
80
87
  if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
81
88
  if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
82
- if (key.return) {
83
- if (mode === 'local') onLocalProvider?.(provider);
84
- else setStep('key');
85
- }
89
+ if (key.return) setStep('key');
86
90
  return;
87
91
  }
88
92
 
@@ -132,7 +136,10 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
132
136
  {step === 'key' && (
133
137
  <Box flexDirection="column">
134
138
  <Text>
135
- Enter the API key for {provider} <Text dimColor>(stored as {envKey})</Text>
139
+ Enter the API key for {provider}{' '}
140
+ <Text dimColor>
141
+ (stored as {envKey}, create one at {PROVIDERS[provider].keyUrl})
142
+ </Text>
136
143
  </Text>
137
144
  <Box borderStyle="single" borderColor="blue" paddingX={1}>
138
145
  <InputReadline value={apiKey} onChange={setApiKey} onSubmit={() => setStep('validate')} placeholder={process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key'} isActive showPrompt={false} mask />
@@ -147,15 +154,40 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
147
154
  {error && (
148
155
  <Box flexDirection="column">
149
156
  <Text color="red">{error}</Text>
150
- <Text dimColor>r: re-enter the key | n: save anyway</Text>
157
+ <Text dimColor>r: re-enter the key | n: continue anyway</Text>
151
158
  </Box>
152
159
  )}
153
160
  </Box>
154
161
  )}
155
162
 
163
+ {step === 'url' && (
164
+ <Box flexDirection="column">
165
+ <Text>
166
+ What is the URL of the web application to test? <Text dimColor>(written to web.url in the config)</Text>
167
+ </Text>
168
+ <Box borderStyle="single" borderColor="blue" paddingX={1}>
169
+ <InputReadline
170
+ value={url}
171
+ onChange={setUrl}
172
+ onSubmit={() => {
173
+ if (!URL.canParse(url.trim())) {
174
+ setUrlError('Enter a full URL, e.g. http://localhost:3000');
175
+ return;
176
+ }
177
+ onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
178
+ }}
179
+ placeholder="http://localhost:3000"
180
+ isActive
181
+ showPrompt={false}
182
+ />
183
+ </Box>
184
+ {urlError && <Text color="red">{urlError}</Text>}
185
+ </Box>
186
+ )}
187
+
156
188
  <Box marginTop={1}>
157
189
  <Text dimColor>
158
- Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
190
+ Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
159
191
  </Text>
160
192
  </Box>
161
193
  </Box>
package/src/config.ts CHANGED
@@ -11,13 +11,13 @@ import { deepMerge } from './utils/merge.js';
11
11
  import { log, tag } from './utils/logger.js';
12
12
 
13
13
  export const PROVIDERS: Record<string, ProviderInfo> = {
14
- openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
15
- anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
16
- google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
17
- groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
18
- mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
19
- openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
20
- sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
14
+ openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
15
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
16
+ google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
17
+ groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
18
+ mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
19
+ openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
20
+ sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
21
21
  };
22
22
 
23
23
  export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
@@ -169,6 +169,7 @@ interface AgentsConfig {
169
169
 
170
170
  interface AIConfig {
171
171
  model: any;
172
+ recommendedModels?: string;
172
173
  apiKey?: string;
173
174
  config?: Record<string, any>;
174
175
  langfuse?: {
@@ -890,11 +891,34 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
890
891
  if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
891
892
  }
892
893
 
894
+ await fillRecommendedModels(ai);
895
+
893
896
  for (const agent of Object.values(ai.agents || {})) {
894
897
  if (typeof agent?.model === 'string') agent.model = await resolveModel(agent.model);
895
898
  }
896
899
  }
897
900
 
901
+ async function fillRecommendedModels(ai: AIConfig): Promise<void> {
902
+ const provider = ai.recommendedModels;
903
+ if (!provider) return;
904
+
905
+ const recommended = ConfigParser.recommendedModels()[provider];
906
+ if (!recommended) {
907
+ throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
908
+ }
909
+
910
+ for (const role of MODEL_ROLES) {
911
+ if (ai[role]) continue;
912
+ if (!recommended[role]) continue;
913
+ ai[role] = await resolveModel(provider, role);
914
+ }
915
+
916
+ if (!ai.decisionModel && recommended.decisionModel) ai.decisionModel = { provider, model: recommended.decisionModel };
917
+
918
+ const missing = missingModelRoles(provider);
919
+ if (missing.length) tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
920
+ }
921
+
898
922
  export function resolveLangfuse(ai?: AIConfig): void {
899
923
  if (!ai) return;
900
924
 
@@ -994,6 +1018,7 @@ interface ConfiguredModel {
994
1018
 
995
1019
  interface ProviderInfo {
996
1020
  envKey: string;
1021
+ keyUrl: string;
997
1022
  load: () => Promise<(modelId: string) => any>;
998
1023
  }
999
1024
 
package/src/explorbot.ts CHANGED
@@ -137,6 +137,7 @@ export class ExplorBot {
137
137
  if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
138
138
  if (this.options.optionalAi) return this.bootstrapOptionalProvider();
139
139
  this.provider = new AIProvider(this.config.ai);
140
+ this.provider.setJudge(this.judge());
140
141
  await this.provider.validateConnection();
141
142
  }
142
143
 
@@ -266,8 +267,11 @@ export class ExplorBot {
266
267
  return new Tester(deps, researcher, navigator, tools);
267
268
  });
268
269
 
269
- const qm = this.agentQuartermaster();
270
- if (qm) this.agents.tester.setQuartermaster(qm);
270
+ if (this.config.ai?.agents?.quartermaster?.enabled === true) {
271
+ const quartermaster = this.agentQuartermaster();
272
+ quartermaster.start(this.stateManager());
273
+ this.agents.tester.setQuartermaster(quartermaster);
274
+ }
271
275
  if (this.isHistorianEnabled()) this.agents.tester.setHistorian(this.agentHistorian());
272
276
  this.agents.tester.setPilot(this.agentPilot());
273
277
  this.agents.tester.setCaptain(this.agentCaptain());
@@ -289,17 +293,8 @@ export class ExplorBot {
289
293
  return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.experienceTracker()));
290
294
  }
291
295
 
292
- agentQuartermaster(): Quartermaster | null {
293
- const config = this.config.ai?.agents?.quartermaster;
294
- if (config?.enabled !== true) return null;
295
-
296
- if (!this.agents.quartermaster) {
297
- this.agents.quartermaster = new Quartermaster(this.provider, {
298
- model: config?.model,
299
- });
300
- this.agents.quartermaster.start(this.explorer, this.stateManager());
301
- }
302
- return this.agents.quartermaster;
296
+ agentQuartermaster(): Quartermaster {
297
+ return (this.agents.quartermaster ||= new Quartermaster(this.provider, this.explorer, { model: this.config.ai?.agents?.quartermaster?.model }));
303
298
  }
304
299
 
305
300
  agentHistorian(): Historian {
@@ -571,6 +566,7 @@ export class ExplorBot {
571
566
  private async bootstrapOptionalProvider(): Promise<void> {
572
567
  try {
573
568
  const provider = new AIProvider(this.config.ai);
569
+ provider.setJudge(this.judge());
574
570
  await provider.validateConnection();
575
571
  this.provider = provider;
576
572
  } catch (error) {
package/src/explorer.ts CHANGED
@@ -104,6 +104,7 @@ class Explorer {
104
104
  throw new Error('Playwright helper not available');
105
105
  }
106
106
  this.applyBasePathToHelper();
107
+ codeceptjs.store.visibleLocator = this.playwrightHelper.options?.visibleLocator;
107
108
  await this.connectOrLaunchBrowser();
108
109
  const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
109
110
  await this.openContextPage();
@@ -6,6 +6,7 @@ import dedent from 'dedent';
6
6
 
7
7
  const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
8
8
  const SITE_DIRS = ['knowledge', 'experience', 'output'];
9
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
9
10
 
10
11
  export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
11
12
 
@@ -34,7 +35,9 @@ export function sitesDir(): string {
34
35
  }
35
36
 
36
37
  export function siteFolderName(url: string): string {
37
- return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
38
+ const parsed = new URL(url);
39
+ if (LOOPBACK_HOSTS.includes(parsed.hostname)) parsed.hostname = 'localhost';
40
+ return parsed.host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
38
41
  }
39
42
 
40
43
  export function listSites(): SiteRecord[] {
@@ -81,7 +84,7 @@ function validateSiteConfig(config: any, configPath: string, baseUrl: string): v
81
84
  }
82
85
 
83
86
  const declared = URL.parse(url)?.origin;
84
- if (declared === baseUrl) return;
87
+ if (declared && siteFolderName(declared) === siteFolderName(baseUrl)) return;
85
88
 
86
89
  throw new Error(dedent`
87
90
  Site config declares a different site.
@@ -0,0 +1,163 @@
1
+ import { HTML_SELECTORS, HTML_VISIBILITY_LIMITS } from './html.ts';
2
+
3
+ export const UX_FOCUS_ATTR = 'data-explorbot-ux';
4
+
5
+ const UX_LIMITS = {
6
+ touchTarget: 44,
7
+ pointerTarget: 24,
8
+ touchWidth: 1024,
9
+ targetGap: 8,
10
+ minFontSize: 12,
11
+ minLineHeight: 1.3,
12
+ maxEntries: 8,
13
+ minOpacity: HTML_VISIBILITY_LIMITS.minOpacity,
14
+ };
15
+
16
+ export function uxProbeArgs(scope: string): UxProbeArgs {
17
+ return { scope, controlsSelector: HTML_SELECTORS.interactiveControl, limits: UX_LIMITS };
18
+ }
19
+
20
+ export function renderUxMeasurements(measurements: UxMeasurements): string {
21
+ const lines = [`viewport: ${measurements.width}px wide`];
22
+ for (const [check, entries] of Object.entries(measurements.checks)) {
23
+ if (!entries.length) continue;
24
+ lines.push(`${check}:`, ...entries.map((entry) => ` - ${entry}`));
25
+ }
26
+ if (lines.length === 1) lines.push('no measurable problems');
27
+ return lines.join('\n');
28
+ }
29
+
30
+ // Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
31
+ export function measureUx({ scope, controlsSelector, limits }: UxProbeArgs): UxMeasurements {
32
+ const root = document.querySelector(scope) || document.body;
33
+ const width = window.innerWidth;
34
+ const touch = width <= limits.touchWidth;
35
+ const visibleWidth = document.documentElement.clientWidth;
36
+ const checks: Record<string, string[]> = {
37
+ 'horizontal overflow': [],
38
+ 'zoom and viewport meta': [],
39
+ 'small targets': [],
40
+ 'crowded targets': [],
41
+ 'placeholder-only labels': [],
42
+ 'small text': [],
43
+ 'cramped line-height': [],
44
+ 'truncated text without full text': [],
45
+ 'layout-property transitions': [],
46
+ };
47
+
48
+ const add = (check: string, entry: string) => {
49
+ if (checks[check].length >= limits.maxEntries) return;
50
+ if (checks[check].includes(entry)) return;
51
+ checks[check].push(entry);
52
+ };
53
+
54
+ const describe = (el: Element) => {
55
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
56
+ const name = (el.getAttribute('aria-label') || (el as HTMLElement).innerText || el.getAttribute('placeholder') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ');
57
+ if (!name) return `${role} (unnamed)`;
58
+ return `${role} "${name.slice(0, 40)}"`;
59
+ };
60
+
61
+ const visible = (el: Element) => {
62
+ const rect = el.getBoundingClientRect();
63
+ if (!rect.width || !rect.height) return false;
64
+ const style = window.getComputedStyle(el);
65
+ return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > limits.minOpacity;
66
+ };
67
+
68
+ if (root === document.body || root.matches('main, [role="main"]')) {
69
+ const docWidth = document.documentElement.scrollWidth;
70
+ if (docWidth > visibleWidth + 1) add('horizontal overflow', `page is ${docWidth}px wide in a ${visibleWidth}px visible area`);
71
+
72
+ const meta = document.querySelector('meta[name="viewport"]')?.getAttribute('content');
73
+ if (!meta) add('zoom and viewport meta', 'no viewport meta tag');
74
+ const metaRules = (meta || '').toLowerCase().replace(/\s/g, '');
75
+ if (metaRules.includes('user-scalable=no') || metaRules.includes('user-scalable=0')) add('zoom and viewport meta', 'zoom disabled by user-scalable');
76
+ const maxScale = metaRules.split(',').find((part) => part.startsWith('maximum-scale='));
77
+ if (maxScale && Number(maxScale.split('=')[1]) <= 1) add('zoom and viewport meta', `zoom capped by ${maxScale}`);
78
+ }
79
+
80
+ const minTarget = touch ? limits.touchTarget : limits.pointerTarget;
81
+ const controls = Array.from(root.querySelectorAll(controlsSelector)).filter(visible);
82
+ const boxes = controls.map((el) => ({ el, rect: el.getBoundingClientRect() }));
83
+ for (const { el, rect } of boxes) {
84
+ if (el.tagName === 'A' && window.getComputedStyle(el).display === 'inline') continue;
85
+ if (rect.width >= minTarget && rect.height >= minTarget) continue;
86
+ add('small targets', `${describe(el)} is ${Math.round(rect.width)}×${Math.round(rect.height)}px, below ${minTarget}px`);
87
+ }
88
+
89
+ if (touch) {
90
+ for (let i = 0; i < boxes.length; i++) {
91
+ for (let j = i + 1; j < boxes.length; j++) {
92
+ const a = boxes[i];
93
+ const b = boxes[j];
94
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
95
+ const gapX = Math.max(b.rect.left - a.rect.right, a.rect.left - b.rect.right);
96
+ const gapY = Math.max(b.rect.top - a.rect.bottom, a.rect.top - b.rect.bottom);
97
+ const gap = Math.max(gapX, gapY);
98
+ if (gap >= limits.targetGap) continue;
99
+ if (a.rect.height >= limits.touchTarget && b.rect.height >= limits.touchTarget && a.rect.width >= limits.touchTarget && b.rect.width >= limits.touchTarget) continue;
100
+ add('crowded targets', `${describe(a.el)} and ${describe(b.el)} are ${Math.max(0, Math.round(gap))}px apart`);
101
+ }
102
+ }
103
+ }
104
+
105
+ for (const el of Array.from(root.querySelectorAll('input[placeholder], textarea[placeholder]'))) {
106
+ if (!visible(el)) continue;
107
+ if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title')) continue;
108
+ if ((el as HTMLInputElement).labels?.length) continue;
109
+ add('placeholder-only labels', describe(el));
110
+ }
111
+
112
+ for (const el of Array.from(root.querySelectorAll('*'))) {
113
+ if (!visible(el)) continue;
114
+ const rect = el.getBoundingClientRect();
115
+ const style = window.getComputedStyle(el);
116
+
117
+ const overflows = rect.right > visibleWidth + 1 && rect.left < visibleWidth;
118
+ if (overflows && !(el.parentElement && el.parentElement.getBoundingClientRect().right > visibleWidth + 1)) add('horizontal overflow', `${describe(el)} ends at ${Math.round(rect.right)}px`);
119
+
120
+ const animated = style.transitionProperty.split(',').map((property) => property.trim());
121
+ const layout = animated.filter((property) => ['width', 'height', 'top', 'left', 'right', 'bottom', 'margin', 'padding'].includes(property));
122
+ const transitions = style.transitionDuration.split(',').some((duration) => Number.parseFloat(duration) > 0);
123
+ if (transitions && layout.length) add('layout-property transitions', `${describe(el)} transitions ${layout.join(', ')}`);
124
+
125
+ const ownText = Array.from(el.childNodes)
126
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
127
+ .map((node) => node.textContent || '')
128
+ .join('')
129
+ .trim();
130
+ if (!ownText) continue;
131
+
132
+ const fontSize = Number.parseFloat(style.fontSize);
133
+ if (fontSize < limits.minFontSize) add('small text', `"${ownText.slice(0, 40)}" at ${fontSize}px`);
134
+
135
+ const lineHeight = Number.parseFloat(style.lineHeight);
136
+ const multiline = rect.height > fontSize * 2.5;
137
+ if (multiline && lineHeight && lineHeight / fontSize < limits.minLineHeight) add('cramped line-height', `"${ownText.slice(0, 40)}" at ${(lineHeight / fontSize).toFixed(2)}`);
138
+
139
+ const htmlEl = el as HTMLElement;
140
+ if (style.textOverflow === 'ellipsis' && htmlEl.scrollWidth > htmlEl.clientWidth && !el.getAttribute('title')) add('truncated text without full text', `"${ownText.slice(0, 40)}"`);
141
+ }
142
+
143
+ return { width, checks };
144
+ }
145
+
146
+ export function countRunningAnimations(scope: string): number {
147
+ const root = document.querySelector(scope) || document.body;
148
+ return document.getAnimations().filter((animation) => {
149
+ const target = (animation.effect as KeyframeEffect | null)?.target;
150
+ return animation.playState === 'running' && target && root.contains(target);
151
+ }).length;
152
+ }
153
+
154
+ interface UxProbeArgs {
155
+ scope: string;
156
+ controlsSelector: string;
157
+ limits: typeof UX_LIMITS;
158
+ }
159
+
160
+ export interface UxMeasurements {
161
+ width: number;
162
+ checks: Record<string, string[]>;
163
+ }